@mlx-node/server 0.0.13 → 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
package/src/host/swap.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-resident lazy-load policy for an inference host.
|
|
3
|
+
*
|
|
4
|
+
* The host discovers every local model up-front but loads at most one into
|
|
5
|
+
* the `ModelRegistry` at a time. Switching models (e.g. via Claude Code's
|
|
6
|
+
* `/model` picker, or the desktop app's model menu) unregisters the previous
|
|
7
|
+
* instance, letting GC + native destructors reclaim memory, before loading
|
|
8
|
+
* the new one.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LoadableModel, SessionCapableModel } from '@mlx-node/lm';
|
|
12
|
+
|
|
13
|
+
import type { PublicModelEntry } from '../handler.js';
|
|
14
|
+
import type { ModelRegistry } from '../registry.js';
|
|
15
|
+
import type { DiscoveredModel } from './discover.js';
|
|
16
|
+
|
|
17
|
+
export interface SwapController {
|
|
18
|
+
resolveModel: (name: string) => Promise<void>;
|
|
19
|
+
listModels: () => PublicModelEntry[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build the `resolveModel` + `listModels` callbacks for the handler.
|
|
24
|
+
*
|
|
25
|
+
* `loadModelFn` is injected so tests can stub it without touching native code.
|
|
26
|
+
* The controller serializes every `resolveModel` invocation on a single
|
|
27
|
+
* promise chain so two concurrent requests for different-but-currently-
|
|
28
|
+
* unloaded models cannot race on the native compiled-path globals.
|
|
29
|
+
*/
|
|
30
|
+
export function makeSwapController(
|
|
31
|
+
discovered: DiscoveredModel[],
|
|
32
|
+
registry: ModelRegistry,
|
|
33
|
+
loadModelFn: (path: string) => Promise<LoadableModel>,
|
|
34
|
+
defaultName?: string,
|
|
35
|
+
): SwapController {
|
|
36
|
+
const byName = new Map<string, DiscoveredModel>();
|
|
37
|
+
for (const entry of discovered) byName.set(entry.name, entry);
|
|
38
|
+
|
|
39
|
+
const ordered = [...discovered].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
40
|
+
|
|
41
|
+
// Which entry unknown names (haiku subagent dispatches, etc.) fall back
|
|
42
|
+
// to before anything is resident. Defaults to discovered[0], but the
|
|
43
|
+
// caller can pin it to the user's `--model` pick so the first haiku
|
|
44
|
+
// title-gen doesn't trigger a load of the alphabetically-first model
|
|
45
|
+
// followed by an immediate swap to the user's real choice.
|
|
46
|
+
const fallbackEntry = (defaultName != null ? byName.get(defaultName) : undefined) ?? discovered[0];
|
|
47
|
+
|
|
48
|
+
let resident: { name: string } | null = null;
|
|
49
|
+
// Names we registered as aliases to the current resident (e.g.
|
|
50
|
+
// Claude Code's hardcoded `claude-haiku-*` for subagent dispatches /
|
|
51
|
+
// title generation). Tracked so we can unregister them on `/model`
|
|
52
|
+
// swap — otherwise an alias's refcount would keep the old binding
|
|
53
|
+
// alive past the user's swap.
|
|
54
|
+
const aliases = new Set<string>();
|
|
55
|
+
let currentOp: Promise<unknown> = Promise.resolve();
|
|
56
|
+
|
|
57
|
+
async function resolveModel(name: string): Promise<void> {
|
|
58
|
+
// Fast path: already registered under this name (either as a real
|
|
59
|
+
// resident or as an alias we previously installed). Avoid chaining.
|
|
60
|
+
if (registry.get(name)) return;
|
|
61
|
+
|
|
62
|
+
const next = currentOp.then(async () => {
|
|
63
|
+
// Re-check under the serialized section — a prior waiter may have loaded it.
|
|
64
|
+
if (registry.get(name)) return;
|
|
65
|
+
|
|
66
|
+
// Pick the discovered entry to resolve against. If the requested
|
|
67
|
+
// name matches a discovered model, use it. Otherwise (unknown
|
|
68
|
+
// name — Claude Code's hardcoded small-fast-model, etc.) fall
|
|
69
|
+
// through to the current resident so subagent dispatches don't
|
|
70
|
+
// 404, loading discovered[0] on first boot if nothing is resident
|
|
71
|
+
// yet.
|
|
72
|
+
//
|
|
73
|
+
// CRITICAL: this must read `resident` at RUN time, not QUEUE time.
|
|
74
|
+
// If we capture it before chaining onto `currentOp`, a swap that
|
|
75
|
+
// ran ahead of us will leave us with a stale target — e.g. a haiku
|
|
76
|
+
// alias request that arrived during a `/model a → b` switch would
|
|
77
|
+
// capture `targetEntry = a`, then re-bind itself to `a` and undo
|
|
78
|
+
// the user's switch when its turn finally comes around.
|
|
79
|
+
const knownEntry = byName.get(name);
|
|
80
|
+
const targetEntry = knownEntry ?? (resident ? (byName.get(resident.name) ?? fallbackEntry) : fallbackEntry);
|
|
81
|
+
const isAlias = targetEntry.name !== name;
|
|
82
|
+
|
|
83
|
+
// Swap out any stale resident that isn't the target.
|
|
84
|
+
//
|
|
85
|
+
// We do NOT unregister the aliases here: in-flight messages.ts
|
|
86
|
+
// requests may be microtask-racing between "resolveModel returned"
|
|
87
|
+
// and "registry.get(body.model)" and dropping the alias in that
|
|
88
|
+
// window yields a spurious 404. Instead we carry the alias set
|
|
89
|
+
// across the swap and re-point them to the new resident below,
|
|
90
|
+
// so the name always resolves to *some* live instance.
|
|
91
|
+
const oldResident = resident;
|
|
92
|
+
const carriedAliases = new Set(aliases);
|
|
93
|
+
if (oldResident && oldResident.name !== targetEntry.name) {
|
|
94
|
+
// Drop our local alias bookkeeping AND the old resident's primary
|
|
95
|
+
// name binding, but leave the alias *names* in the registry pointed
|
|
96
|
+
// at the old model — they hold the only refcount preventing GC,
|
|
97
|
+
// and an in-flight `registry.get(alias)` must keep resolving to
|
|
98
|
+
// *some* live instance until the new model is in hand.
|
|
99
|
+
aliases.clear();
|
|
100
|
+
registry.unregister(oldResident.name);
|
|
101
|
+
resident = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Ensure the target is resident. If the load throws, restore the
|
|
105
|
+
// pre-swap controller state so future swap attempts know about the
|
|
106
|
+
// aliases we just cleared — otherwise the alias *names* stay bound
|
|
107
|
+
// in the registry to the old model object forever (alias bindings
|
|
108
|
+
// hold their own refcount), but the controller forgets they exist
|
|
109
|
+
// and never repoints them, leaving alias-routed traffic permanently
|
|
110
|
+
// pinned to a stale model.
|
|
111
|
+
let instance = registry.get(targetEntry.name);
|
|
112
|
+
if (!instance) {
|
|
113
|
+
let loaded: LoadableModel;
|
|
114
|
+
try {
|
|
115
|
+
loaded = await loadModelFn(targetEntry.path);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Recovery: re-populate the controller's alias set so the next
|
|
118
|
+
// resolveModel call still owns them. The alias→old-model bindings
|
|
119
|
+
// are still live in the registry (we never unregistered them), so
|
|
120
|
+
// we can recover the old model object via any surviving alias and
|
|
121
|
+
// re-bind the old resident's primary name.
|
|
122
|
+
//
|
|
123
|
+
// If `carriedAliases` is empty (no aliases ever existed) AND we
|
|
124
|
+
// unregistered `oldResident.name`, the binding's refcount may have
|
|
125
|
+
// hit zero and the model is gone. There's nothing the controller
|
|
126
|
+
// can do to recover in that case — the user will need to /model-
|
|
127
|
+
// pick again. This is acceptable: alias-less load failures are
|
|
128
|
+
// rare, and the user-facing symptom is "prior model gone, please
|
|
129
|
+
// re-pick", not silently-wrong responses.
|
|
130
|
+
for (const aliasName of carriedAliases) aliases.add(aliasName);
|
|
131
|
+
if (oldResident) {
|
|
132
|
+
let oldInstance: SessionCapableModel | undefined;
|
|
133
|
+
for (const aliasName of carriedAliases) {
|
|
134
|
+
const probe = registry.get(aliasName);
|
|
135
|
+
if (probe) {
|
|
136
|
+
oldInstance = probe;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (oldInstance) {
|
|
141
|
+
const oldEntry = byName.get(oldResident.name);
|
|
142
|
+
registry.register(oldResident.name, oldInstance, {
|
|
143
|
+
samplingDefaults: oldEntry?.preset.sampling,
|
|
144
|
+
maxOutputTokens: oldEntry?.preset.maxOutputTokens,
|
|
145
|
+
});
|
|
146
|
+
resident = oldResident;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
instance = loaded as unknown as SessionCapableModel;
|
|
152
|
+
registry.register(targetEntry.name, instance, {
|
|
153
|
+
samplingDefaults: targetEntry.preset.sampling,
|
|
154
|
+
maxOutputTokens: targetEntry.preset.maxOutputTokens,
|
|
155
|
+
});
|
|
156
|
+
resident = { name: targetEntry.name };
|
|
157
|
+
} else if (!resident) {
|
|
158
|
+
resident = { name: targetEntry.name };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Re-point any aliases carried across the swap onto the new
|
|
162
|
+
// resident. `registry.register(sameName, differentModel)` drops
|
|
163
|
+
// the old binding's refcount and installs the new one atomically,
|
|
164
|
+
// so any concurrent `registry.get(alias)` either sees the old or
|
|
165
|
+
// new instance — never null.
|
|
166
|
+
for (const aliasName of carriedAliases) {
|
|
167
|
+
if (aliasName === targetEntry.name) continue;
|
|
168
|
+
registry.register(aliasName, instance, {
|
|
169
|
+
samplingDefaults: targetEntry.preset.sampling,
|
|
170
|
+
maxOutputTokens: targetEntry.preset.maxOutputTokens,
|
|
171
|
+
});
|
|
172
|
+
aliases.add(aliasName);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// For unknown names, register an alias on the resident instance so
|
|
176
|
+
// the endpoint's `registry.get(name)` lookup succeeds.
|
|
177
|
+
if (isAlias) {
|
|
178
|
+
registry.register(name, instance, {
|
|
179
|
+
samplingDefaults: targetEntry.preset.sampling,
|
|
180
|
+
maxOutputTokens: targetEntry.preset.maxOutputTokens,
|
|
181
|
+
});
|
|
182
|
+
aliases.add(name);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
currentOp = next.catch(() => undefined);
|
|
186
|
+
await next;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function listModels(): PublicModelEntry[] {
|
|
190
|
+
const created = Math.floor(Date.now() / 1000);
|
|
191
|
+
return ordered.map((entry) => ({
|
|
192
|
+
id: entry.name,
|
|
193
|
+
object: 'model',
|
|
194
|
+
created,
|
|
195
|
+
owned_by: 'mlx-node',
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { resolveModel, listModels };
|
|
200
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pid-scoped temp roots for the host's paged-config overrides, plus the
|
|
3
|
+
* startup sweep that reclaims the ones a killed host left behind.
|
|
4
|
+
*
|
|
5
|
+
* `PagedConfigOverrideManager` clones a checkpoint directory (config.json
|
|
6
|
+
* rewritten, everything else symlinked) into a temp root and removes that root
|
|
7
|
+
* in `cleanup()`. `cleanup()` runs on a normal shutdown — but the host's
|
|
8
|
+
* headline deployment is an Electron `utilityProcess`, and a `utilityProcess`
|
|
9
|
+
* can be SIGKILLed (app force-quit, OOM killer, `kill -9`). SIGKILL runs no
|
|
10
|
+
* handler, so without a sweep EVERY hard kill leaks a root. The clones are
|
|
11
|
+
* symlink farms rather than copies, so the leak is inodes and directory
|
|
12
|
+
* entries rather than model-sized bytes — but the roots accumulate forever and
|
|
13
|
+
* a partially-written clone can hold a real config.json.
|
|
14
|
+
*
|
|
15
|
+
* The reclaim strategy is "name the owner in the directory name": the manager
|
|
16
|
+
* gets a `tempDirPrefix` of `mlx-inference-host-<pid>-`, `mkdtemp` appends its
|
|
17
|
+
* own random suffix, and {@link sweepOrphanHostTempRoots} parses the pid back
|
|
18
|
+
* out and removes any root whose owner is gone.
|
|
19
|
+
*
|
|
20
|
+
* Known limitation: pid reuse. A long-dead host's root whose pid has since
|
|
21
|
+
* been recycled by an unrelated process is SPARED (never wrongly deleted), so
|
|
22
|
+
* the failure mode is a leaked directory, not data loss.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readdir, rm } from 'node:fs/promises';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
/** Shared stem. A directory is host-owned iff its name starts with this. */
|
|
30
|
+
export const HOST_TEMP_DIR_STEM = 'mlx-inference-host-';
|
|
31
|
+
|
|
32
|
+
/** Matches `mlx-inference-host-<pid>-<mkdtemp suffix>`. */
|
|
33
|
+
const HOST_TEMP_DIR_RE = /^mlx-inference-host-(\d+)-/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `tempDirPrefix` to hand `PagedConfigOverrideManager` so the root it creates
|
|
37
|
+
* carries its owner's pid.
|
|
38
|
+
*/
|
|
39
|
+
export function hostTempDirPrefix(pid: number = process.pid): string {
|
|
40
|
+
return `${HOST_TEMP_DIR_STEM}${pid}-`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Does a process with this pid exist?
|
|
45
|
+
*
|
|
46
|
+
* `process.kill(pid, 0)` sends no signal; it only performs the permission +
|
|
47
|
+
* existence check. `ESRCH` is the sole "gone" answer — `EPERM` means the
|
|
48
|
+
* process exists but belongs to another user, which must count as ALIVE so a
|
|
49
|
+
* multi-user box never has one user's sweep delete another user's live root.
|
|
50
|
+
*/
|
|
51
|
+
export function isProcessAlive(pid: number): boolean {
|
|
52
|
+
// pid 0 addresses the caller's whole process group on POSIX and pid < 0 a
|
|
53
|
+
// group by id; neither is ever a real owner, and signalling them would be
|
|
54
|
+
// actively dangerous. Treat as alive so they are never swept.
|
|
55
|
+
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
56
|
+
try {
|
|
57
|
+
process.kill(pid, 0);
|
|
58
|
+
return true;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return (err as NodeJS.ErrnoException).code !== 'ESRCH';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SweepOrphanTempRootsOptions {
|
|
65
|
+
/** Directory to scan. Defaults to the OS temp dir. */
|
|
66
|
+
root?: string;
|
|
67
|
+
/** Our own pid — never swept, however the liveness probe answers. */
|
|
68
|
+
selfPid?: number;
|
|
69
|
+
/** Injectable liveness probe. Defaults to {@link isProcessAlive}. */
|
|
70
|
+
isAlive?: (pid: number) => boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Remove every host temp root whose owning pid is no longer alive.
|
|
75
|
+
*
|
|
76
|
+
* Best effort by contract: a scan or unlink failure (permissions, a root a
|
|
77
|
+
* concurrently-exiting host is removing under us) is swallowed, because a
|
|
78
|
+
* housekeeping step must never be the reason a host refuses to start.
|
|
79
|
+
* Returns the absolute paths actually removed.
|
|
80
|
+
*/
|
|
81
|
+
export async function sweepOrphanHostTempRoots(opts: SweepOrphanTempRootsOptions = {}): Promise<string[]> {
|
|
82
|
+
const root = opts.root ?? tmpdir();
|
|
83
|
+
const selfPid = opts.selfPid ?? process.pid;
|
|
84
|
+
const isAlive = opts.isAlive ?? isProcessAlive;
|
|
85
|
+
|
|
86
|
+
let entries: string[];
|
|
87
|
+
try {
|
|
88
|
+
entries = await readdir(root);
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const removed: string[] = [];
|
|
94
|
+
for (const name of entries) {
|
|
95
|
+
const match = HOST_TEMP_DIR_RE.exec(name);
|
|
96
|
+
if (match === null) continue;
|
|
97
|
+
const pid = Number.parseInt(match[1], 10);
|
|
98
|
+
if (pid === selfPid) continue;
|
|
99
|
+
if (isAlive(pid)) continue;
|
|
100
|
+
|
|
101
|
+
const full = join(root, name);
|
|
102
|
+
try {
|
|
103
|
+
await rm(full, { recursive: true, force: true });
|
|
104
|
+
removed.push(full);
|
|
105
|
+
} catch {
|
|
106
|
+
/* another sweeper won the race, or we lack permission; leave it */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return removed;
|
|
110
|
+
}
|