@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.
Files changed (45) hide show
  1. package/dist/host/discover.d.ts +3 -6
  2. package/dist/host/discover.d.ts.map +1 -1
  3. package/dist/host/discover.js +9 -42
  4. package/dist/host/index.d.ts +2 -2
  5. package/dist/host/index.d.ts.map +1 -1
  6. package/dist/host/index.js +8 -1
  7. package/package.json +9 -4
  8. package/src/auth.ts +111 -0
  9. package/src/chat-session-warm-reuse.ts +96 -0
  10. package/src/endpoints/messages-count-tokens.ts +164 -0
  11. package/src/endpoints/messages.ts +1802 -0
  12. package/src/endpoints/models.ts +20 -0
  13. package/src/endpoints/responses.ts +3928 -0
  14. package/src/errors.ts +120 -0
  15. package/src/handler.ts +195 -0
  16. package/src/health.ts +213 -0
  17. package/src/host/discover.ts +25 -0
  18. package/src/host/env-policy.ts +81 -0
  19. package/src/host/index.ts +496 -0
  20. package/src/host/logger.ts +419 -0
  21. package/src/host/net.ts +100 -0
  22. package/src/host/paths.ts +77 -0
  23. package/src/host/swap.ts +200 -0
  24. package/src/host/temp-root.ts +110 -0
  25. package/src/idle-sweeper.ts +555 -0
  26. package/src/index.ts +114 -0
  27. package/src/load-model.ts +92 -0
  28. package/src/mappers/anthropic-request.ts +485 -0
  29. package/src/mappers/anthropic-response.ts +306 -0
  30. package/src/mappers/request.ts +456 -0
  31. package/src/mappers/response.ts +163 -0
  32. package/src/model-work-coordinator.ts +416 -0
  33. package/src/pending-writes.ts +481 -0
  34. package/src/registry.ts +691 -0
  35. package/src/router.ts +220 -0
  36. package/src/server.ts +579 -0
  37. package/src/session-registry.ts +1371 -0
  38. package/src/stop-sequence-buffer.ts +161 -0
  39. package/src/streaming.ts +205 -0
  40. package/src/text-recovery.ts +41 -0
  41. package/src/timing.ts +236 -0
  42. package/src/tool-call-buffer.ts +78 -0
  43. package/src/transport-visibility.ts +185 -0
  44. package/src/types-anthropic.ts +409 -0
  45. package/src/types.ts +470 -0
@@ -0,0 +1,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
+ }