@mlx-node/server 0.0.8 → 0.0.9
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/auth.d.ts +56 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +106 -0
- package/dist/chat-session-warm-reuse.d.ts +8 -8
- package/dist/chat-session-warm-reuse.d.ts.map +1 -1
- package/dist/chat-session-warm-reuse.js +12 -8
- package/dist/endpoints/responses.d.ts +3 -1
- package/dist/endpoints/responses.d.ts.map +1 -1
- package/dist/endpoints/responses.js +38 -5
- package/dist/handler.d.ts +27 -1
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +65 -16
- package/dist/health.d.ts +146 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +107 -0
- package/dist/host/discover.d.ts +19 -0
- package/dist/host/discover.d.ts.map +1 -0
- package/dist/host/discover.js +50 -0
- package/dist/host/env-policy.d.ts +62 -0
- package/dist/host/env-policy.d.ts.map +1 -0
- package/dist/host/env-policy.js +69 -0
- package/dist/host/index.d.ts +202 -0
- package/dist/host/index.d.ts.map +1 -0
- package/dist/host/index.js +325 -0
- package/dist/host/logger.d.ts +36 -0
- package/dist/host/logger.d.ts.map +1 -0
- package/dist/host/logger.js +376 -0
- package/dist/host/net.d.ts +65 -0
- package/dist/host/net.d.ts.map +1 -0
- package/dist/host/net.js +97 -0
- package/dist/host/paths.d.ts +28 -0
- package/dist/host/paths.d.ts.map +1 -0
- package/dist/host/paths.js +71 -0
- package/dist/host/swap.d.ts +27 -0
- package/dist/host/swap.d.ts.map +1 -0
- package/dist/host/swap.js +178 -0
- package/dist/host/temp-root.d.ts +57 -0
- package/dist/host/temp-root.d.ts.map +1 -0
- package/dist/host/temp-root.js +99 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/load-model.d.ts +69 -0
- package/dist/load-model.d.ts.map +1 -0
- package/dist/load-model.js +63 -0
- package/dist/model-work-coordinator.d.ts +29 -4
- package/dist/model-work-coordinator.d.ts.map +1 -1
- package/dist/model-work-coordinator.js +97 -16
- package/dist/router.d.ts +34 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +47 -5
- package/dist/server.d.ts +117 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +125 -9
- package/dist/session-registry.d.ts +7 -0
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +9 -0
- package/dist/streaming.d.ts +14 -0
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +45 -0
- package/package.json +15 -3
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional bearer-token gate.
|
|
3
|
+
*
|
|
4
|
+
* Enforced at the single choke point in `createHandler`'s returned closure —
|
|
5
|
+
* after the CORS/OPTIONS early-return, before `routeRequest` — so there is
|
|
6
|
+
* exactly one place a route can be added without inheriting the gate.
|
|
7
|
+
*
|
|
8
|
+
* Scope, deliberately narrow: this is a shared-secret check for a server
|
|
9
|
+
* bound to loopback and supervised by a local app. It is NOT a
|
|
10
|
+
* multi-tenant auth system — there is one token, no rotation, no scopes,
|
|
11
|
+
* no per-caller identity.
|
|
12
|
+
*/
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
14
|
+
/**
|
|
15
|
+
* Extract the presented credential, or `null` when none is usable.
|
|
16
|
+
*
|
|
17
|
+
* `x-api-key` is checked FIRST because Anthropic clients (including Claude
|
|
18
|
+
* Code, the primary consumer of `/v1/messages`) send it. Checking it first
|
|
19
|
+
* also means a stale `authorization` header injected by an intermediary
|
|
20
|
+
* cannot shadow the caller's real key.
|
|
21
|
+
*
|
|
22
|
+
* Array-valued headers are rejected outright. Node collapses repeated
|
|
23
|
+
* non-allowlisted headers such as `x-api-key` into a `string[]`; joining or
|
|
24
|
+
* picking one arbitrarily would let a caller smuggle a second value past a
|
|
25
|
+
* front proxy that only inspected the first.
|
|
26
|
+
*/
|
|
27
|
+
export declare function extractPresentedToken(req: IncomingMessage): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Constant-time-ish credential comparison.
|
|
30
|
+
*
|
|
31
|
+
* The length check in front of `timingSafeEqual` is unavoidable — the
|
|
32
|
+
* primitive throws on mismatched lengths. It therefore LEAKS the length of
|
|
33
|
+
* the configured token to an attacker who can time responses. That is an
|
|
34
|
+
* accepted trade: the token is a locally-generated high-entropy secret, and
|
|
35
|
+
* knowing its length does not meaningfully reduce the search space. The
|
|
36
|
+
* byte-by-byte content comparison, which is the part that would otherwise
|
|
37
|
+
* allow incremental guessing, stays constant-time.
|
|
38
|
+
*/
|
|
39
|
+
export declare function tokensMatch(presented: string, expected: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* `true` when the caller offered SOME credential, valid or not.
|
|
42
|
+
*
|
|
43
|
+
* Used only by the `/health` carve-out: an anonymous poll degrades to a
|
|
44
|
+
* liveness-only body, but a caller who presented a wrong (or malformed)
|
|
45
|
+
* credential gets a 401 so a mistyped token surfaces instead of masquerading
|
|
46
|
+
* as a healthy 200.
|
|
47
|
+
*/
|
|
48
|
+
export declare function hasCredential(req: IncomingMessage): boolean;
|
|
49
|
+
/** `true` when the request carries a credential matching `expected`. */
|
|
50
|
+
export declare function isAuthorized(req: IncomingMessage, expected: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* 401 with `WWW-Authenticate: Bearer`. The body deliberately says nothing
|
|
53
|
+
* about whether a credential was absent, malformed, or simply wrong.
|
|
54
|
+
*/
|
|
55
|
+
export declare function sendUnauthorized(res: ServerResponse): void;
|
|
56
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAKjE;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAYzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAaxE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAE3D;AAED,wEAAwE;AACxE,wBAAgB,YAAY,CAAC,GAAG,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAI5E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI,CAe1D"}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional bearer-token gate.
|
|
3
|
+
*
|
|
4
|
+
* Enforced at the single choke point in `createHandler`'s returned closure —
|
|
5
|
+
* after the CORS/OPTIONS early-return, before `routeRequest` — so there is
|
|
6
|
+
* exactly one place a route can be added without inheriting the gate.
|
|
7
|
+
*
|
|
8
|
+
* Scope, deliberately narrow: this is a shared-secret check for a server
|
|
9
|
+
* bound to loopback and supervised by a local app. It is NOT a
|
|
10
|
+
* multi-tenant auth system — there is one token, no rotation, no scopes,
|
|
11
|
+
* no per-caller identity.
|
|
12
|
+
*/
|
|
13
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
14
|
+
/** `Bearer ` prefix length, used to slice the credential out of `authorization`. */
|
|
15
|
+
const BEARER_PREFIX = 'bearer ';
|
|
16
|
+
/**
|
|
17
|
+
* Extract the presented credential, or `null` when none is usable.
|
|
18
|
+
*
|
|
19
|
+
* `x-api-key` is checked FIRST because Anthropic clients (including Claude
|
|
20
|
+
* Code, the primary consumer of `/v1/messages`) send it. Checking it first
|
|
21
|
+
* also means a stale `authorization` header injected by an intermediary
|
|
22
|
+
* cannot shadow the caller's real key.
|
|
23
|
+
*
|
|
24
|
+
* Array-valued headers are rejected outright. Node collapses repeated
|
|
25
|
+
* non-allowlisted headers such as `x-api-key` into a `string[]`; joining or
|
|
26
|
+
* picking one arbitrarily would let a caller smuggle a second value past a
|
|
27
|
+
* front proxy that only inspected the first.
|
|
28
|
+
*/
|
|
29
|
+
export function extractPresentedToken(req) {
|
|
30
|
+
const apiKey = req.headers['x-api-key'];
|
|
31
|
+
if (apiKey !== undefined) {
|
|
32
|
+
return typeof apiKey === 'string' ? apiKey : null;
|
|
33
|
+
}
|
|
34
|
+
const authorization = req.headers.authorization;
|
|
35
|
+
if (typeof authorization !== 'string')
|
|
36
|
+
return null;
|
|
37
|
+
// Scheme is case-insensitive per RFC 7235; the credential is not.
|
|
38
|
+
if (authorization.length <= BEARER_PREFIX.length)
|
|
39
|
+
return null;
|
|
40
|
+
if (authorization.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX)
|
|
41
|
+
return null;
|
|
42
|
+
return authorization.slice(BEARER_PREFIX.length);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Constant-time-ish credential comparison.
|
|
46
|
+
*
|
|
47
|
+
* The length check in front of `timingSafeEqual` is unavoidable — the
|
|
48
|
+
* primitive throws on mismatched lengths. It therefore LEAKS the length of
|
|
49
|
+
* the configured token to an attacker who can time responses. That is an
|
|
50
|
+
* accepted trade: the token is a locally-generated high-entropy secret, and
|
|
51
|
+
* knowing its length does not meaningfully reduce the search space. The
|
|
52
|
+
* byte-by-byte content comparison, which is the part that would otherwise
|
|
53
|
+
* allow incremental guessing, stays constant-time.
|
|
54
|
+
*/
|
|
55
|
+
export function tokensMatch(presented, expected) {
|
|
56
|
+
// The empty string is not a credential, in either position. `timingSafeEqual`
|
|
57
|
+
// on two zero-length buffers returns TRUE, so a server misconfigured with an
|
|
58
|
+
// empty token would authenticate every caller who sent an empty `x-api-key`.
|
|
59
|
+
// `resolveAuthToken` rejects an empty token before it can reach a server built
|
|
60
|
+
// through `createServer`; this is the second latch, for a caller that
|
|
61
|
+
// constructs `createHandler` directly. Refusing is the fail-closed direction —
|
|
62
|
+
// an empty token 401s everything rather than admitting everything.
|
|
63
|
+
if (presented === '' || expected === '')
|
|
64
|
+
return false;
|
|
65
|
+
const presentedBytes = Buffer.from(presented, 'utf8');
|
|
66
|
+
const expectedBytes = Buffer.from(expected, 'utf8');
|
|
67
|
+
if (presentedBytes.length !== expectedBytes.length)
|
|
68
|
+
return false;
|
|
69
|
+
return timingSafeEqual(presentedBytes, expectedBytes);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `true` when the caller offered SOME credential, valid or not.
|
|
73
|
+
*
|
|
74
|
+
* Used only by the `/health` carve-out: an anonymous poll degrades to a
|
|
75
|
+
* liveness-only body, but a caller who presented a wrong (or malformed)
|
|
76
|
+
* credential gets a 401 so a mistyped token surfaces instead of masquerading
|
|
77
|
+
* as a healthy 200.
|
|
78
|
+
*/
|
|
79
|
+
export function hasCredential(req) {
|
|
80
|
+
return req.headers['x-api-key'] !== undefined || req.headers.authorization !== undefined;
|
|
81
|
+
}
|
|
82
|
+
/** `true` when the request carries a credential matching `expected`. */
|
|
83
|
+
export function isAuthorized(req, expected) {
|
|
84
|
+
const presented = extractPresentedToken(req);
|
|
85
|
+
if (presented === null)
|
|
86
|
+
return false;
|
|
87
|
+
return tokensMatch(presented, expected);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 401 with `WWW-Authenticate: Bearer`. The body deliberately says nothing
|
|
91
|
+
* about whether a credential was absent, malformed, or simply wrong.
|
|
92
|
+
*/
|
|
93
|
+
export function sendUnauthorized(res) {
|
|
94
|
+
res.writeHead(401, {
|
|
95
|
+
'WWW-Authenticate': 'Bearer realm="mlx-node"',
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
});
|
|
98
|
+
res.end(JSON.stringify({
|
|
99
|
+
error: {
|
|
100
|
+
type: 'authentication_error',
|
|
101
|
+
message: 'Missing or invalid API key',
|
|
102
|
+
code: null,
|
|
103
|
+
param: null,
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
@@ -22,14 +22,14 @@
|
|
|
22
22
|
* native cache is correct there and only there.
|
|
23
23
|
*
|
|
24
24
|
* Fields accessed: `inFlight`, `history`, `lastImagesKey`, `lastAudioKey`, `turnCount`,
|
|
25
|
-
* `unresolvedOkToolCallCount`, `needsFullReplay
|
|
26
|
-
* `ChatSession` (compile-time
|
|
27
|
-
* properties. The cast through
|
|
28
|
-
* gives this helper a typed view
|
|
29
|
-
* class's `private` declarations.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
25
|
+
* `unresolvedOkToolCallCount`, `needsFullReplay`, `defaultConfig`, `activeTools`.
|
|
26
|
+
* These are TypeScript `private` fields on `ChatSession` (compile-time
|
|
27
|
+
* only) — at runtime they are ordinary properties. The cast through
|
|
28
|
+
* {@link ChatSessionWarmReuseInternals} gives this helper a typed view
|
|
29
|
+
* of the instance without relaxing the class's `private` declarations.
|
|
30
|
+
* The field names MUST stay in sync with
|
|
31
|
+
* `packages/lm/src/chat-session.ts`; a mismatch would silently skip
|
|
32
|
+
* the intended state wipe and is covered by the warm-reuse unit tests.
|
|
33
33
|
*/
|
|
34
34
|
import type { ChatSession, SessionCapableModel } from '@mlx-node/lm';
|
|
35
35
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chat-session-warm-reuse.d.ts","sourceRoot":"","sources":["../src/chat-session-warm-reuse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"chat-session-warm-reuse.d.ts","sourceRoot":"","sources":["../src/chat-session-warm-reuse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAc,WAAW,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAqBjF;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,sCAAsC,CAAC,CAAC,SAAS,mBAAmB,EACxF,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC,GACtB,OAAO,CAAC,IAAI,CAAC,CAyBf"}
|
|
@@ -22,14 +22,14 @@
|
|
|
22
22
|
* native cache is correct there and only there.
|
|
23
23
|
*
|
|
24
24
|
* Fields accessed: `inFlight`, `history`, `lastImagesKey`, `lastAudioKey`, `turnCount`,
|
|
25
|
-
* `unresolvedOkToolCallCount`, `needsFullReplay
|
|
26
|
-
* `ChatSession` (compile-time
|
|
27
|
-
* properties. The cast through
|
|
28
|
-
* gives this helper a typed view
|
|
29
|
-
* class's `private` declarations.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
25
|
+
* `unresolvedOkToolCallCount`, `needsFullReplay`, `defaultConfig`, `activeTools`.
|
|
26
|
+
* These are TypeScript `private` fields on `ChatSession` (compile-time
|
|
27
|
+
* only) — at runtime they are ordinary properties. The cast through
|
|
28
|
+
* {@link ChatSessionWarmReuseInternals} gives this helper a typed view
|
|
29
|
+
* of the instance without relaxing the class's `private` declarations.
|
|
30
|
+
* The field names MUST stay in sync with
|
|
31
|
+
* `packages/lm/src/chat-session.ts`; a mismatch would silently skip
|
|
32
|
+
* the intended state wipe and is covered by the warm-reuse unit tests.
|
|
33
33
|
*/
|
|
34
34
|
/**
|
|
35
35
|
* JS-state-only reset that DELIBERATELY preserves the underlying
|
|
@@ -65,4 +65,8 @@ export async function resetPreservingNativeCacheForWarmReuse(session) {
|
|
|
65
65
|
internals.turnCount = 0;
|
|
66
66
|
internals.unresolvedOkToolCallCount = null;
|
|
67
67
|
internals.needsFullReplay = false;
|
|
68
|
+
// Tools are conversation state. A warm-any lease may belong to an
|
|
69
|
+
// unrelated request, so restore constructor defaults exactly like
|
|
70
|
+
// ChatSession.reset() instead of leaking the prior committed overlay.
|
|
71
|
+
internals.activeTools = internals.defaultConfig?.tools;
|
|
68
72
|
}
|
|
@@ -88,5 +88,7 @@ export declare function __setServerBootIdForTesting(id: string): void;
|
|
|
88
88
|
* `tool_result` / `tool_use_id`). Validation logic is identical.
|
|
89
89
|
*/
|
|
90
90
|
export declare function validateAndCanonicalizeHistoryToolOrder(messages: ChatMessage[], apiSurface?: 'openai' | 'anthropic'): string | null;
|
|
91
|
-
export declare function handleCreateResponse(res: ServerResponse, body: ResponsesAPIRequest, registry: ModelRegistry, store: ResponseStore | null, httpReq?: IncomingMessage, responseRetentionSec?: number, idleSweeper?: IdleSweeper | null, modelWorkCoordinator?: ModelWorkCoordinator
|
|
91
|
+
export declare function handleCreateResponse(res: ServerResponse, body: ResponsesAPIRequest, registry: ModelRegistry, store: ResponseStore | null, httpReq?: IncomingMessage, responseRetentionSec?: number, idleSweeper?: IdleSweeper | null, modelWorkCoordinator?: ModelWorkCoordinator,
|
|
92
|
+
/** Lazy-load hook. See the call site below and `ServerConfig.resolveModel`. */
|
|
93
|
+
resolveModel?: (name: string) => Promise<void>): Promise<void>;
|
|
92
94
|
//# sourceMappingURL=responses.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"responses.d.ts","sourceRoot":"","sources":["../../src/endpoints/responses.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAc,WAAW,EAAc,aAAa,EAAwB,MAAM,gBAAgB,CAAC;AAM/G,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAStD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAcpD,OAAO,KAAK,EAMV,mBAAmB,EACpB,MAAM,aAAa,CAAC;AAUrB;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,aAAa,CAAC;AAU5C;;;;;;;;;GASG;AACH,MAAM,MAAM,kBAAkB,GAAG,KAAK,GAAG,aAAa,GAAG,OAAO,GAAG,YAAY,CAAC;AAmChF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iCAAiC,IAAI,MAAM,CAM1D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CAMnD;AA0BD,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,wBAAgB,2BAA2B,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAE5D;AAs9BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,WAAW,EAAE,EACvB,UAAU,GAAE,QAAQ,GAAG,WAAsB,GAC5C,MAAM,GAAG,IAAI,CAiHf;
|
|
1
|
+
{"version":3,"file":"responses.d.ts","sourceRoot":"","sources":["../../src/endpoints/responses.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAc,WAAW,EAAc,aAAa,EAAwB,MAAM,gBAAgB,CAAC;AAM/G,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAStD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAcpD,OAAO,KAAK,EAMV,mBAAmB,EACpB,MAAM,aAAa,CAAC;AAUrB;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,aAAa,CAAC;AAU5C;;;;;;;;;GASG;AACH,MAAM,MAAM,kBAAkB,GAAG,KAAK,GAAG,aAAa,GAAG,OAAO,GAAG,YAAY,CAAC;AAmChF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iCAAiC,IAAI,MAAM,CAM1D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,IAAI,MAAM,CAMnD;AA0BD,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,wBAAgB,2BAA2B,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAE5D;AAs9BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,WAAW,EAAE,EACvB,UAAU,GAAE,QAAQ,GAAG,WAAsB,GAC5C,MAAM,GAAG,IAAI,CAiHf;AA+WD,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,mBAAmB,EACzB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,aAAa,GAAG,IAAI,EAC3B,OAAO,CAAC,EAAE,eAAe,EACzB,oBAAoB,CAAC,EAAE,MAAM,EAC7B,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,EAChC,oBAAoB,CAAC,EAAE,oBAAoB;AAC3C,+EAA+E;AAC/E,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAC7C,OAAO,CAAC,IAAI,CAAC,CAsiEf"}
|
|
@@ -1175,10 +1175,11 @@ async function runSessionNonStreaming(session, messages, newInputMessages, confi
|
|
|
1175
1175
|
}
|
|
1176
1176
|
// Hot path — session's KV cache is already warmed for this chain.
|
|
1177
1177
|
// Single-message continuations whose role is `user` or `tool` take
|
|
1178
|
-
// the
|
|
1178
|
+
// the session paths (`send` / `sendToolResult`), which render the full
|
|
1179
|
+
// transcript and reuse KV on an exact token-prefix match. Any other single
|
|
1179
1180
|
// role (`assistant`, `system`) is still accepted by `mapRequest` —
|
|
1180
1181
|
// `reconstructMessagesFromChain` + `primeHistory` tolerate a tail of
|
|
1181
|
-
// either — but the chat-session
|
|
1182
|
+
// either — but the high-level chat-session API has no entry point for
|
|
1182
1183
|
// them, so fall through to reset + cold re-prime against the fully
|
|
1183
1184
|
// rebuilt history. Returning 500 here would regress the pre-session-
|
|
1184
1185
|
// API full-history path, making valid continuation payloads fail
|
|
@@ -1412,7 +1413,9 @@ function readStoredModelIdentity(record) {
|
|
|
1412
1413
|
// ---------------------------------------------------------------------------
|
|
1413
1414
|
// Public handler
|
|
1414
1415
|
// ---------------------------------------------------------------------------
|
|
1415
|
-
export async function handleCreateResponse(res, body, registry, store, httpReq, responseRetentionSec, idleSweeper, modelWorkCoordinator
|
|
1416
|
+
export async function handleCreateResponse(res, body, registry, store, httpReq, responseRetentionSec, idleSweeper, modelWorkCoordinator,
|
|
1417
|
+
/** Lazy-load hook. See the call site below and `ServerConfig.resolveModel`. */
|
|
1418
|
+
resolveModel) {
|
|
1416
1419
|
const handlerStartedAt = Date.now();
|
|
1417
1420
|
// Validate required fields
|
|
1418
1421
|
if (body == null || typeof body !== 'object') {
|
|
@@ -1472,6 +1475,36 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
1472
1475
|
}
|
|
1473
1476
|
}
|
|
1474
1477
|
const effectiveRetentionSec = requestedRetentionSec ?? responseRetentionSec;
|
|
1478
|
+
// Lazy load, exactly as the Anthropic endpoints do. Nothing is resident at
|
|
1479
|
+
// boot — `createInferenceHost` only discovers — so without this the very
|
|
1480
|
+
// first `/v1/responses` 404s against a `/v1/models` list that advertises the
|
|
1481
|
+
// model, and a client id that exists only as an alias 404s forever.
|
|
1482
|
+
//
|
|
1483
|
+
// Placement is pinned on both sides. AFTER the pure validation above, so a
|
|
1484
|
+
// 400 cannot burn a 30 s load or evict the resident model. BEFORE
|
|
1485
|
+
// `registry.get` below, and therefore before the dispatch lease, which needs
|
|
1486
|
+
// a registered name.
|
|
1487
|
+
if (resolveModel) {
|
|
1488
|
+
// Errors serialize through the OpenAI envelope here. Letting them reach
|
|
1489
|
+
// the outer `createHandler` catch would be right for this endpoint by
|
|
1490
|
+
// accident and wrong for the Anthropic one — `messages.ts` has the mirror
|
|
1491
|
+
// of this note for the opposite reason.
|
|
1492
|
+
try {
|
|
1493
|
+
// Suspension OUTSIDE the writer lock, per `load-model.ts`: a load that
|
|
1494
|
+
// parks in `acquireWrite()` must already be covered, or the drain timer
|
|
1495
|
+
// armed by the previous request fires mid-materialization. `messages.ts`
|
|
1496
|
+
// nests these the other way round, which is safe only because
|
|
1497
|
+
// `withSuspendedDrains` is a counter rather than a mutex.
|
|
1498
|
+
const load = () => modelWorkCoordinator
|
|
1499
|
+
? modelWorkCoordinator.withModelLoad(() => resolveModel(body.model), 'responses')
|
|
1500
|
+
: resolveModel(body.model);
|
|
1501
|
+
await (idleSweeper ? idleSweeper.withSuspendedDrains(load) : load());
|
|
1502
|
+
}
|
|
1503
|
+
catch (err) {
|
|
1504
|
+
sendInternalError(res, err instanceof Error ? err.message : 'Failed to resolve model');
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1475
1508
|
// Look up model
|
|
1476
1509
|
const model = registry.get(body.model);
|
|
1477
1510
|
if (!model) {
|
|
@@ -2126,7 +2159,7 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
2126
2159
|
// removed on hit so overlapping requests against the same prior id
|
|
2127
2160
|
// cannot race on the same single-flight ChatSession).
|
|
2128
2161
|
//
|
|
2129
|
-
// Hot-path eligibility gate: the chat-session
|
|
2162
|
+
// Hot-path eligibility gate: the high-level chat-session API only
|
|
2130
2163
|
// serves a SINGLE `user` or `tool` continuation message — the
|
|
2131
2164
|
// `send` / `sendToolResult` entry points cover exactly that
|
|
2132
2165
|
// shape. A single `assistant` / `system` continuation cannot
|
|
@@ -2135,7 +2168,7 @@ export async function handleCreateResponse(res, body, registry, store, httpReq,
|
|
|
2135
2168
|
// still VALID — it just routes through `runSession*`'s
|
|
2136
2169
|
// `session.turns === 0` fall-through (`primeHistory` +
|
|
2137
2170
|
// `startFromHistory*`) instead of the `send` / `sendToolResult`
|
|
2138
|
-
//
|
|
2171
|
+
// session continuation path. Crucially, a tier-1 HIT on this branch is still
|
|
2139
2172
|
// useful: `resetPreservingNativeCacheForWarmReuse(session)` keeps
|
|
2140
2173
|
// the warm native KV cache, and the subsequent
|
|
2141
2174
|
// `chat_session_start_sync` -> `verify_cache_prefix_direct`
|
package/dist/handler.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Composable `(req, res)` handler for node:http — usable standalone or mounted into an existing server. */
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
3
|
import type { ResponseStore } from '@mlx-node/core';
|
|
4
|
+
import { type ServerHealth } from './health.js';
|
|
4
5
|
import type { IdleSweeper } from './idle-sweeper.js';
|
|
5
6
|
import { ModelWorkCoordinator } from './model-work-coordinator.js';
|
|
6
7
|
import type { ModelRegistry } from './registry.js';
|
|
@@ -17,7 +18,14 @@ export interface PublicModelEntry {
|
|
|
17
18
|
owned_by: string;
|
|
18
19
|
}
|
|
19
20
|
export interface HandlerOptions {
|
|
20
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Enable CORS headers.
|
|
23
|
+
*
|
|
24
|
+
* Default: `true` when no `authToken` is set (historical behaviour), and
|
|
25
|
+
* `false` once one is — `Access-Control-Allow-Origin: *` on a
|
|
26
|
+
* token-protected server would invite any web page to spend a leaked token
|
|
27
|
+
* from the user's browser. An explicit value always wins.
|
|
28
|
+
*/
|
|
21
29
|
cors?: boolean;
|
|
22
30
|
/** Response store for previous_response_id support. */
|
|
23
31
|
store?: ResponseStore | null;
|
|
@@ -55,6 +63,24 @@ export interface HandlerOptions {
|
|
|
55
63
|
* returns this list instead of `registry.list()`.
|
|
56
64
|
*/
|
|
57
65
|
listModels?: () => PublicModelEntry[];
|
|
66
|
+
/**
|
|
67
|
+
* Shared secret required on every route except `/health` and `/v1/health`.
|
|
68
|
+
*
|
|
69
|
+
* `undefined` (the default) disables the gate entirely and is byte-for-byte
|
|
70
|
+
* identical to the pre-auth behaviour: no header is inspected, no
|
|
71
|
+
* `WWW-Authenticate` is emitted, and CORS keeps its `true` default.
|
|
72
|
+
*
|
|
73
|
+
* Accepted as `x-api-key: <token>` (checked first — Anthropic clients send
|
|
74
|
+
* it) or `authorization: Bearer <token>` (scheme case-insensitive).
|
|
75
|
+
*/
|
|
76
|
+
authToken?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Builds the `/health` body. Supplied by `createServer` so the HTTP
|
|
79
|
+
* endpoint and `ServerInstance.health()` share one uptime origin. When
|
|
80
|
+
* omitted, `createHandler` builds its own reporter from `registry`,
|
|
81
|
+
* `idleSweeper` and `modelWorkCoordinator`.
|
|
82
|
+
*/
|
|
83
|
+
health?: () => ServerHealth;
|
|
58
84
|
}
|
|
59
85
|
export declare function createHandler(registry: ModelRegistry, options?: HandlerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
60
86
|
//# sourceMappingURL=handler.d.ts.map
|
package/dist/handler.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,4GAA4G;AAE5G,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,4GAA4G;AAE5G,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAIpD,OAAO,EAAwB,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAoBnD;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,uDAAuD;IACvD,KAAK,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IACjC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C;;;OAGG;IACH,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,gBAAgB,EAAE,CAAC;IACtC;;;;;;;;;OASG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,YAAY,CAAC;CAC7B;AAED,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,cAAc,GACvB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAgF9D"}
|
package/dist/handler.js
CHANGED
|
@@ -1,31 +1,80 @@
|
|
|
1
1
|
/** Composable `(req, res)` handler for node:http — usable standalone or mounted into an existing server. */
|
|
2
|
+
import { hasCredential, isAuthorized, sendUnauthorized } from './auth.js';
|
|
2
3
|
import { sendInternalError } from './errors.js';
|
|
4
|
+
import { createHealthReporter } from './health.js';
|
|
3
5
|
import { ModelWorkCoordinator } from './model-work-coordinator.js';
|
|
4
|
-
import { routeRequest } from './router.js';
|
|
6
|
+
import { requestPathname, routeRequest } from './router.js';
|
|
7
|
+
/**
|
|
8
|
+
* Routes reachable WITHOUT a token when one is configured.
|
|
9
|
+
*
|
|
10
|
+
* A supervisor has to poll liveness before it can be handed a token (it may
|
|
11
|
+
* be the thing that generates it). Everything served here is scrubbed to
|
|
12
|
+
* `{ status, uptimeMs, pid }` by the router when the caller is
|
|
13
|
+
* unauthenticated — see `toMinimalHealth`.
|
|
14
|
+
*
|
|
15
|
+
* `/` is included because it is a pure liveness stub: the router answers it
|
|
16
|
+
* with a constant `{ service: 'mlx-node' }` and reads no state, so there is
|
|
17
|
+
* nothing to leak. Claude Code issues `HEAD /` before its first request — it
|
|
18
|
+
* does send `x-api-key`, so it would pass the check anyway, but gating a
|
|
19
|
+
* contentless probe would 401 every other client's liveness check for no
|
|
20
|
+
* security gain.
|
|
21
|
+
*/
|
|
22
|
+
const UNAUTHENTICATED_PATHS = new Set(['/', '/health', '/v1/health']);
|
|
5
23
|
export function createHandler(registry, options) {
|
|
6
|
-
const
|
|
24
|
+
const authToken = options?.authToken;
|
|
25
|
+
// CORS defaults follow the auth posture; an explicit value still wins.
|
|
26
|
+
const cors = options?.cors ?? authToken === undefined;
|
|
7
27
|
const store = options?.store ?? null;
|
|
8
28
|
const responseRetentionSec = options?.responseRetentionSec;
|
|
9
29
|
const idleSweeper = options?.idleSweeper ?? null;
|
|
10
30
|
const resolveModel = options?.resolveModel;
|
|
11
31
|
const modelWorkCoordinator = options?.modelWorkCoordinator ?? (resolveModel ? new ModelWorkCoordinator() : undefined);
|
|
12
32
|
const listModels = options?.listModels;
|
|
33
|
+
const health = options?.health ?? createHealthReporter({ registry, idleSweeper, modelWorkCoordinator: modelWorkCoordinator });
|
|
13
34
|
return async (req, res) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
res.writeHead(204);
|
|
20
|
-
res.end();
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
// Returning the promise lets tests await the full lifecycle including
|
|
25
|
-
// post-`res.end()` bookkeeping (e.g. `SessionRegistry.adopt`). `http.createServer`
|
|
26
|
-
// ignores the return value, so this is transparent to production callers.
|
|
35
|
+
// The guard spans the WHOLE listener, not just `routeRequest`. `http.createServer`
|
|
36
|
+
// discards the returned promise, so anything that escapes here is an unhandled
|
|
37
|
+
// rejection — and Node's default `--unhandled-rejections=throw` turns that into
|
|
38
|
+
// process death. A crash-by-request is the one failure a request handler must
|
|
39
|
+
// not have, so nothing before the routing call gets to be outside the try either.
|
|
27
40
|
try {
|
|
28
|
-
|
|
41
|
+
if (cors) {
|
|
42
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
43
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
44
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-api-key, anthropic-version');
|
|
45
|
+
if (req.method === 'OPTIONS') {
|
|
46
|
+
res.writeHead(204);
|
|
47
|
+
res.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Single auth choke point. `routeRequest` is called from exactly one
|
|
52
|
+
// place (below), so no route can be added that bypasses this.
|
|
53
|
+
//
|
|
54
|
+
// `authToken === undefined` short-circuits before any header is touched:
|
|
55
|
+
// an unprotected server behaves exactly as it did before auth existed.
|
|
56
|
+
let authenticated = true;
|
|
57
|
+
if (authToken !== undefined) {
|
|
58
|
+
authenticated = isAuthorized(req, authToken);
|
|
59
|
+
if (!authenticated) {
|
|
60
|
+
// Host-independent: see `requestPathname`. Building the base from the
|
|
61
|
+
// `Host` header threw on `Host: [`, from a branch only an
|
|
62
|
+
// UNAUTHENTICATED request reaches — so enabling auth was what made the
|
|
63
|
+
// server killable by anyone who could open the socket.
|
|
64
|
+
const path = requestPathname(req);
|
|
65
|
+
// `/health` degrades to a liveness-only body instead of 401 — but
|
|
66
|
+
// ONLY when no credential was offered. A caller who presented a
|
|
67
|
+
// WRONG token gets the 401 it needs to notice the typo.
|
|
68
|
+
if (!UNAUTHENTICATED_PATHS.has(path) || hasCredential(req)) {
|
|
69
|
+
sendUnauthorized(res);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Returning the promise lets tests await the full lifecycle including
|
|
75
|
+
// post-`res.end()` bookkeeping (e.g. `SessionRegistry.adopt`). `http.createServer`
|
|
76
|
+
// ignores the return value, so this is transparent to production callers.
|
|
77
|
+
await routeRequest(req, res, registry, store, responseRetentionSec, idleSweeper, resolveModel, listModels, modelWorkCoordinator, { health, authenticated });
|
|
29
78
|
}
|
|
30
79
|
catch (err) {
|
|
31
80
|
const message = err instanceof Error ? err.message : 'Internal server error';
|
package/dist/health.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server readiness reporting for supervisors (e.g. an Electron app running
|
|
3
|
+
* the inference server as a child process).
|
|
4
|
+
*
|
|
5
|
+
* The old `/health` returned a constant `{ status: 'ok' }`, which cannot
|
|
6
|
+
* distinguish four states a supervisor genuinely needs to tell apart:
|
|
7
|
+
*
|
|
8
|
+
* - up, nothing loaded → keep waiting, this is normal
|
|
9
|
+
* - up, model resident → route traffic
|
|
10
|
+
* - wedged mid-load → keep waiting, do NOT restart
|
|
11
|
+
* - wedged behind a full queue → shed load / warn the user
|
|
12
|
+
*
|
|
13
|
+
* Every input already exists in-process; this module only exposes it. The
|
|
14
|
+
* status ladder itself is a PURE function ({@link deriveHealthStatus}) over a
|
|
15
|
+
* plain record so it can be unit-tested without a server, a registry, or the
|
|
16
|
+
* native addon.
|
|
17
|
+
*
|
|
18
|
+
* IMPORTANT: nothing here may call into `@mlx-node/core`. `/health` is
|
|
19
|
+
* deliberately excluded from the idle sweeper's `beginRequest`/`endRequest`
|
|
20
|
+
* bracket (see `HandlerOptions.idleSweeper`), so a native call from this path
|
|
21
|
+
* would run outside the in-flight accounting the drain timer relies on.
|
|
22
|
+
* Every field below is read from plain JavaScript state.
|
|
23
|
+
*/
|
|
24
|
+
import type { IdleSweeper } from './idle-sweeper.js';
|
|
25
|
+
import type { ModelWorkCoordinator } from './model-work-coordinator.js';
|
|
26
|
+
import type { ModelRegistry } from './registry.js';
|
|
27
|
+
/**
|
|
28
|
+
* Coarse readiness classification. Ordered by precedence in
|
|
29
|
+
* {@link deriveHealthStatus} — a higher rung wins even when a lower rung's
|
|
30
|
+
* condition also holds.
|
|
31
|
+
*/
|
|
32
|
+
export type ServerHealthStatus = 'ok' | 'loading' | 'degraded' | 'error';
|
|
33
|
+
/**
|
|
34
|
+
* Outcome of the most recent load bracket, recorded by
|
|
35
|
+
* {@link ModelWorkCoordinator} in the `finally` of `withModelLoad` /
|
|
36
|
+
* `withModelLoadInstrumented`.
|
|
37
|
+
*
|
|
38
|
+
* Before this existed, a `resolveModel` failure became an HTTP 500 and was
|
|
39
|
+
* dropped — a supervisor polling after the fact had no way to learn WHY the
|
|
40
|
+
* server had no resident model.
|
|
41
|
+
*
|
|
42
|
+
* Caveat worth knowing when reading this field: the coordinator brackets
|
|
43
|
+
* EVERY resolve attempt, including the no-op fast path taken when the
|
|
44
|
+
* requested model is already resident. A successful no-op therefore
|
|
45
|
+
* overwrites an earlier failure record. That is why the `'error'` rung is
|
|
46
|
+
* additionally gated on "no resident models" — a server that is answering
|
|
47
|
+
* requests is not in an error state regardless of what the last bracket did.
|
|
48
|
+
*/
|
|
49
|
+
export interface ModelLoadRecord {
|
|
50
|
+
/** Caller-supplied label, normally the model name. `null` when unlabelled. */
|
|
51
|
+
label: string | null;
|
|
52
|
+
/** `Date.now()` at writer-lock acquisition (when the load actually began). */
|
|
53
|
+
startedAt: number;
|
|
54
|
+
/** `Date.now()` when the bracket settled, success or failure. */
|
|
55
|
+
finishedAt: number;
|
|
56
|
+
ok: boolean;
|
|
57
|
+
/** Message of the thrown error, or `null` on success. */
|
|
58
|
+
error: string | null;
|
|
59
|
+
}
|
|
60
|
+
/** Inputs to the pure status ladder. Deliberately primitive so tests can fixture them. */
|
|
61
|
+
export interface HealthStatusInputs {
|
|
62
|
+
/** True while a load holds the coordinator's exclusive writer slot. */
|
|
63
|
+
writerActive: boolean;
|
|
64
|
+
/** Loads parked waiting for the writer slot. */
|
|
65
|
+
waitingWriters: number;
|
|
66
|
+
/** Inference requests currently bracketed by the idle sweeper. */
|
|
67
|
+
inFlight: number;
|
|
68
|
+
/** Distinct model names currently registered. */
|
|
69
|
+
residentModelCount: number;
|
|
70
|
+
/** True when at least one per-model queue is holding its configured max waiters. */
|
|
71
|
+
queueSaturated: boolean;
|
|
72
|
+
/** Most recent load bracket, or `null` if none has settled. */
|
|
73
|
+
lastLoad: ModelLoadRecord | null;
|
|
74
|
+
}
|
|
75
|
+
/** Full readiness body. `{ status: 'ok' }` remains a strict subset of this. */
|
|
76
|
+
export interface ServerHealth {
|
|
77
|
+
status: ServerHealthStatus;
|
|
78
|
+
/** Milliseconds since the reporter was created (≈ server start). */
|
|
79
|
+
uptimeMs: number;
|
|
80
|
+
pid: number;
|
|
81
|
+
models: {
|
|
82
|
+
/** Registered names, including aliases. */
|
|
83
|
+
resident: string[];
|
|
84
|
+
count: number;
|
|
85
|
+
};
|
|
86
|
+
work: {
|
|
87
|
+
inFlight: number;
|
|
88
|
+
/** True while the idle sweeper has a `clearCache()` drain armed. */
|
|
89
|
+
drainPending: boolean;
|
|
90
|
+
writerActive: boolean;
|
|
91
|
+
waitingWriters: number;
|
|
92
|
+
};
|
|
93
|
+
queue: {
|
|
94
|
+
/** Deepest per-model waiter count across every session registry. */
|
|
95
|
+
depth: number;
|
|
96
|
+
/** Configured per-model waiter cap, or `null` when unbounded. */
|
|
97
|
+
limit: number | null;
|
|
98
|
+
saturated: boolean;
|
|
99
|
+
};
|
|
100
|
+
lastLoad: ModelLoadRecord | null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The subset served to an UNAUTHENTICATED `/health` poll on a
|
|
104
|
+
* token-protected server. A supervisor must be able to poll before it holds a
|
|
105
|
+
* token, but `models.resident` is user data — model names routinely leak
|
|
106
|
+
* project names and local paths.
|
|
107
|
+
*/
|
|
108
|
+
export interface ServerHealthMinimal {
|
|
109
|
+
status: ServerHealthStatus;
|
|
110
|
+
uptimeMs: number;
|
|
111
|
+
pid: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Pure status ladder. No I/O, no clock, no native calls — just the four
|
|
115
|
+
* documented rungs, in precedence order:
|
|
116
|
+
*
|
|
117
|
+
* 1. `writerActive` → 'loading'
|
|
118
|
+
* 2. last load failed AND nothing resident → 'error'
|
|
119
|
+
* 3. queue saturated, OR a load is parked
|
|
120
|
+
* behind live inference → 'degraded'
|
|
121
|
+
* 4. otherwise → 'ok'
|
|
122
|
+
*
|
|
123
|
+
* `'loading'` outranks `'error'` on purpose: a retry that already holds the
|
|
124
|
+
* writer slot means the supervisor should wait, not restart the process.
|
|
125
|
+
*/
|
|
126
|
+
export declare function deriveHealthStatus(input: HealthStatusInputs): ServerHealthStatus;
|
|
127
|
+
/** Project the full body down to the three fields safe to serve without a token. */
|
|
128
|
+
export declare function toMinimalHealth(health: ServerHealth): ServerHealthMinimal;
|
|
129
|
+
export interface HealthReporterDeps {
|
|
130
|
+
registry: ModelRegistry;
|
|
131
|
+
/** Optional: supplies `inFlight` / `drainPending`. Absent ⇒ both read as idle. */
|
|
132
|
+
idleSweeper?: IdleSweeper | null;
|
|
133
|
+
/** Optional: supplies writer state + `lastLoad`. Absent ⇒ no load has ever run. */
|
|
134
|
+
modelWorkCoordinator?: ModelWorkCoordinator | null;
|
|
135
|
+
/** Defaults to `Date.now()` at construction. */
|
|
136
|
+
startedAt?: number;
|
|
137
|
+
/** Injectable clock for tests. */
|
|
138
|
+
now?: () => number;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Build a zero-argument reporter closing over the live server objects. Each
|
|
142
|
+
* call re-reads current state; nothing is cached, so a supervisor polling on
|
|
143
|
+
* an interval always sees the present moment.
|
|
144
|
+
*/
|
|
145
|
+
export declare function createHealthReporter(deps: HealthReporterDeps): () => ServerHealth;
|
|
146
|
+
//# sourceMappingURL=health.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"health.d.ts","sourceRoot":"","sources":["../src/health.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,IAAI,GAAG,SAAS,GAAG,UAAU,GAAG,OAAO,CAAC;AAEzE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,8EAA8E;IAC9E,SAAS,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,OAAO,CAAC;IACZ,yDAAyD;IACzD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,0FAA0F;AAC1F,MAAM,WAAW,kBAAkB;IACjC,uEAAuE;IACvE,YAAY,EAAE,OAAO,CAAC;IACtB,gDAAgD;IAChD,cAAc,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC;IACjB,iDAAiD;IACjD,kBAAkB,EAAE,MAAM,CAAC;IAC3B,oFAAoF;IACpF,cAAc,EAAE,OAAO,CAAC;IACxB,+DAA+D;IAC/D,QAAQ,EAAE,eAAe,GAAG,IAAI,CAAC;CAClC;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE;QACN,2CAA2C;QAC3C,QAAQ,EAAE,MAAM,EAAE,CAAC;QACnB,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;IACF,IAAI,EAAE;QACJ,QAAQ,EAAE,MAAM,CAAC;QACjB,oEAAoE;QACpE,YAAY,EAAE,OAAO,CAAC;QACtB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;IACF,KAAK,EAAE;QACL,oEAAoE;QACpE,KAAK,EAAE,MAAM,CAAC;QACd,iEAAiE;QACjE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QACrB,SAAS,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,QAAQ,EAAE,eAAe,GAAG,IAAI,CAAC;CAClC;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,kBAAkB,GAAG,kBAAkB,CAShF;AAED,oFAAoF;AACpF,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,GAAG,mBAAmB,CAEzE;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,aAAa,CAAC;IACxB,kFAAkF;IAClF,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IACjC,mFAAmF;IACnF,oBAAoB,CAAC,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACnD,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kCAAkC;IAClC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,kBAAkB,GAAG,MAAM,YAAY,CAiDjF"}
|