@mlx-node/agent 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/catalog.d.ts +15 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +15 -0
- package/dist/cold-tier.d.ts +99 -0
- package/dist/cold-tier.d.ts.map +1 -0
- package/dist/cold-tier.js +155 -0
- package/dist/extensions/local-image-input.d.ts +24 -0
- package/dist/extensions/local-image-input.d.ts.map +1 -0
- package/dist/extensions/local-image-input.js +114 -0
- package/dist/extensions/subagent.d.ts +20 -1
- package/dist/extensions/subagent.d.ts.map +1 -1
- package/dist/extensions/subagent.js +46 -6
- package/dist/paths.d.ts +13 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +18 -0
- package/dist/provider/events.d.ts +9 -0
- package/dist/provider/events.d.ts.map +1 -1
- package/dist/provider/events.js +15 -0
- package/dist/provider/index.d.ts +12 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +153 -7
- package/dist/provider/metrics-trace.d.ts +274 -0
- package/dist/provider/metrics-trace.d.ts.map +1 -0
- package/dist/provider/metrics-trace.js +174 -0
- package/dist/provider/mlx-identity.d.ts +16 -0
- package/dist/provider/mlx-identity.d.ts.map +1 -0
- package/dist/provider/mlx-identity.js +15 -0
- package/dist/provider/model-host.d.ts +45 -3
- package/dist/provider/model-host.d.ts.map +1 -1
- package/dist/provider/model-host.js +34 -2
- package/dist/provider/model-registry-filter.d.ts +74 -18
- package/dist/provider/model-registry-filter.d.ts.map +1 -1
- package/dist/provider/model-registry-filter.js +229 -38
- package/dist/provider/models.d.ts +2 -3
- package/dist/provider/models.d.ts.map +1 -1
- package/dist/provider/models.js +46 -20
- package/dist/provider/stream-adapter.d.ts +37 -4
- package/dist/provider/stream-adapter.d.ts.map +1 -1
- package/dist/provider/stream-adapter.js +58 -3
- package/dist/provider/warm-reuse.d.ts +11 -8
- package/dist/provider/warm-reuse.d.ts.map +1 -1
- package/dist/provider/warm-reuse.js +14 -7
- package/dist/run-agent.d.ts +12 -3
- package/dist/run-agent.d.ts.map +1 -1
- package/dist/run-agent.js +31 -5
- package/package.json +10 -5
|
@@ -1,72 +1,263 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Process-local policy adapter for pi's
|
|
2
|
+
* Process-local policy adapter for pi's canonical `ModelRuntime`.
|
|
3
3
|
*
|
|
4
|
-
* `mlx agent` is an offline/local product, but pi's
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* runtime
|
|
4
|
+
* `mlx agent` is an offline/local product, but pi's runtime also composes every
|
|
5
|
+
* built-in cloud provider. CLI `--models mlx/*` only sets the initial selector
|
|
6
|
+
* scope: Tab, `/models`, RPC enumeration, explicit model resolution, and
|
|
7
|
+
* restored sessions all read the runtime's unscoped catalog/availability
|
|
8
|
+
* directly (the `ModelRegistry` facade handed to extensions delegates to the
|
|
9
|
+
* same runtime). Filter those reads at their shared boundary — the runtime
|
|
10
|
+
* prototype — so every path sees only the exact local models this process
|
|
11
|
+
* serves. Patching the runtime (not the extension-only facade) is what keeps
|
|
12
|
+
* the mlx-only guarantee across the selector / listing / resolution paths.
|
|
13
|
+
*
|
|
14
|
+
* The guarantee is an ALLOWLIST across three surfaces, all keyed on the `mlx`
|
|
15
|
+
* provider id:
|
|
16
|
+
* 1. Model reads (`getModels`/`getAvailable*`/`getModel`) — exact local-model
|
|
17
|
+
* identity (`api === 'mlx' && baseUrl === 'mlx://local'`).
|
|
18
|
+
* 2. Provider/auth reads (`getProviders`/`getProvider`/`checkAuth`/`getAuth`/
|
|
19
|
+
* `isUsingOAuth`/`hasConfiguredAuth`/`listCredentials`/`getProviderAuthStatus`)
|
|
20
|
+
* — never surface, report configured, resolve auth for, or enumerate a
|
|
21
|
+
* credential of any non-`mlx` provider. `getAuth` is the pivotal one: pi's
|
|
22
|
+
* `/login`, `/logout`, and the built-in `/llama` command all resolve auth
|
|
23
|
+
* through it, and its OAuth/`LLAMA_BASE_URL` `fetch` consults neither
|
|
24
|
+
* `PI_OFFLINE` nor any allow-network flag; returning `undefined` for non-mlx
|
|
25
|
+
* makes those commands fail before any network or second-model-host load.
|
|
26
|
+
* 3. Auth mutation / network (`login` rejected for non-mlx; `refresh` forced to
|
|
27
|
+
* `allowNetwork: false` so an explicit `allowNetwork: true` — e.g. pi's
|
|
28
|
+
* `update --models` package command — can never override offline mode).
|
|
29
|
+
*
|
|
30
|
+
* The `mlx` provider is registered with a literal apiKey and never needs
|
|
31
|
+
* `/login`; streaming still works because `prepareRequest` calls `getAuth(model)`
|
|
32
|
+
* with `model.provider === 'mlx'`, which passes through.
|
|
33
|
+
*
|
|
34
|
+
* Surfaces 1-3 patch the runtime's PUBLIC facade, but pi's own internals read the
|
|
35
|
+
* composed provider map `this.models` (pi-ai `ModelsImpl`) directly — a boundary
|
|
36
|
+
* the facade patches cannot reach (e.g. `refresh` resolving a command-backed
|
|
37
|
+
* cloud credential). So there is a 4th, structural surface: the `recomposeProvider`
|
|
38
|
+
* choke composes ONLY `mlx` into `this.models`, making every internal read
|
|
39
|
+
* mlx-only by construction. It requires installation before the runtime is
|
|
40
|
+
* constructed (which `runAgent` guarantees).
|
|
10
41
|
*
|
|
11
42
|
* Keep this adapter isolated: once pi exposes a first-class provider allowlist
|
|
12
43
|
* in `MainOptions`, this file can be replaced by that option without touching
|
|
13
44
|
* the provider or CLI layers.
|
|
14
45
|
*/
|
|
46
|
+
import { MLX_API, MLX_API_KEY, MLX_BASE_URL, MLX_PROVIDER_ID } from './mlx-identity.js';
|
|
15
47
|
const activePrototypes = new WeakSet();
|
|
48
|
+
/**
|
|
49
|
+
* `getAuth` is intentionally NOT part of {@link FilterableModelRuntime}: pi types
|
|
50
|
+
* it with two overloads (`getAuth(providerId)` and `getAuth(model)`), which a
|
|
51
|
+
* single structural signature cannot capture without breaking the runtime→
|
|
52
|
+
* interface assignment. It is wrapped by name (mlx pins a fixed resolution; the
|
|
53
|
+
* original is never delegated to).
|
|
54
|
+
*/
|
|
55
|
+
/** Method names wrapped through the structural interface, plus the loose `getAuth`. */
|
|
56
|
+
const STRUCTURAL_METHODS = [
|
|
57
|
+
'getModels',
|
|
58
|
+
'getAvailableSnapshot',
|
|
59
|
+
'getAvailable',
|
|
60
|
+
'getModel',
|
|
61
|
+
'getProvider',
|
|
62
|
+
'hasConfiguredAuth',
|
|
63
|
+
'checkAuth',
|
|
64
|
+
'isUsingOAuth',
|
|
65
|
+
'getProviders',
|
|
66
|
+
'listCredentials',
|
|
67
|
+
'getProviderAuthStatus',
|
|
68
|
+
'login',
|
|
69
|
+
'refresh',
|
|
70
|
+
];
|
|
16
71
|
function requireMethodDescriptor(prototype, name) {
|
|
17
72
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
|
|
18
73
|
if (!descriptor || typeof descriptor.value !== 'function' || descriptor.writable !== true) {
|
|
19
|
-
throw new Error(`mlx agent: incompatible pi
|
|
74
|
+
throw new Error(`mlx agent: incompatible pi ModelRuntime.${name}; expected a writable prototype method`);
|
|
20
75
|
}
|
|
21
76
|
return descriptor;
|
|
22
77
|
}
|
|
23
78
|
/**
|
|
24
|
-
* Install an exact local-model
|
|
25
|
-
* Returns an idempotent restore callback.
|
|
79
|
+
* Install an exact local-model / mlx-only-provider policy for one `runAgent()`
|
|
80
|
+
* lifetime. Returns an idempotent restore callback.
|
|
26
81
|
*/
|
|
27
|
-
export function installMlxOnlyModelRegistryFilter(
|
|
28
|
-
const prototype =
|
|
82
|
+
export function installMlxOnlyModelRegistryFilter(Runtime, modelIds) {
|
|
83
|
+
const prototype = Runtime.prototype;
|
|
29
84
|
if (activePrototypes.has(prototype)) {
|
|
30
|
-
throw new Error('mlx agent: concurrent
|
|
85
|
+
throw new Error('mlx agent: concurrent ModelRuntime filtering in one process is not supported');
|
|
31
86
|
}
|
|
32
87
|
const allowedIds = new Set(modelIds);
|
|
33
|
-
const isAllowed = (model) => model.provider ===
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
};
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
88
|
+
const isAllowed = (model) => model.provider === MLX_PROVIDER_ID &&
|
|
89
|
+
allowedIds.has(model.id) &&
|
|
90
|
+
model.api === MLX_API &&
|
|
91
|
+
model.baseUrl === MLX_BASE_URL;
|
|
92
|
+
// Capture every original descriptor up front (fail-closed if pi's method shape
|
|
93
|
+
// changed), so restore can put them all back verbatim.
|
|
94
|
+
const originals = {};
|
|
95
|
+
for (const name of STRUCTURAL_METHODS)
|
|
96
|
+
originals[name] = requireMethodDescriptor(prototype, name);
|
|
97
|
+
originals.getAuth = requireMethodDescriptor(prototype, 'getAuth');
|
|
98
|
+
// `recomposeProvider` is a private funnel method; capture it by name (it is not
|
|
99
|
+
// part of the structural interface — see the choke wrapper below).
|
|
100
|
+
originals.recomposeProvider = requireMethodDescriptor(prototype, 'recomposeProvider');
|
|
101
|
+
const iface = originals;
|
|
102
|
+
const getModels = iface.getModels.value;
|
|
103
|
+
const getAvailableSnapshot = iface.getAvailableSnapshot.value;
|
|
104
|
+
const getAvailable = iface.getAvailable.value;
|
|
105
|
+
const getModel = iface.getModel.value;
|
|
106
|
+
const getProvider = iface.getProvider.value;
|
|
107
|
+
const hasConfiguredAuth = iface.hasConfiguredAuth.value;
|
|
108
|
+
const checkAuth = iface.checkAuth.value;
|
|
109
|
+
const isUsingOAuth = iface.isUsingOAuth.value;
|
|
110
|
+
const getProviders = iface.getProviders.value;
|
|
111
|
+
const listCredentials = iface.listCredentials.value;
|
|
112
|
+
const getProviderAuthStatus = iface.getProviderAuthStatus.value;
|
|
113
|
+
const login = iface.login.value;
|
|
114
|
+
const refresh = iface.refresh.value;
|
|
115
|
+
const recomposeProvider = originals.recomposeProvider.value;
|
|
44
116
|
Object.defineProperties(prototype, {
|
|
45
|
-
|
|
46
|
-
...
|
|
117
|
+
getModels: {
|
|
118
|
+
...originals.getModels,
|
|
119
|
+
value(providerId) {
|
|
120
|
+
return getModels.call(this, providerId).filter(isAllowed);
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
getAvailableSnapshot: {
|
|
124
|
+
...originals.getAvailableSnapshot,
|
|
47
125
|
value() {
|
|
48
|
-
return
|
|
126
|
+
return getAvailableSnapshot.call(this).filter(isAllowed);
|
|
49
127
|
},
|
|
50
128
|
},
|
|
51
129
|
getAvailable: {
|
|
52
|
-
...
|
|
53
|
-
|
|
54
|
-
|
|
130
|
+
...originals.getAvailable,
|
|
131
|
+
// The runtime read is async, so filter the resolved snapshot. Preserve the
|
|
132
|
+
// Promise contract (never turn a rejection into a filtered success).
|
|
133
|
+
value(providerId) {
|
|
134
|
+
return getAvailable.call(this, providerId).then((models) => models.filter(isAllowed));
|
|
55
135
|
},
|
|
56
136
|
},
|
|
57
|
-
|
|
58
|
-
...
|
|
137
|
+
getModel: {
|
|
138
|
+
...originals.getModel,
|
|
59
139
|
value(provider, modelId) {
|
|
60
|
-
if (provider !==
|
|
140
|
+
if (provider !== MLX_PROVIDER_ID || !allowedIds.has(modelId))
|
|
61
141
|
return undefined;
|
|
62
|
-
const model =
|
|
142
|
+
const model = getModel.call(this, provider, modelId);
|
|
63
143
|
return model && isAllowed(model) ? model : undefined;
|
|
64
144
|
},
|
|
65
145
|
},
|
|
146
|
+
getProvider: {
|
|
147
|
+
...originals.getProvider,
|
|
148
|
+
// Streaming uses `this.models.getProvider` (a different object); this only
|
|
149
|
+
// gates external reads (e.g. `/logout`). Non-mlx must never surface.
|
|
150
|
+
value(providerId) {
|
|
151
|
+
return providerId === MLX_PROVIDER_ID ? getProvider.call(this, providerId) : undefined;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
66
154
|
hasConfiguredAuth: {
|
|
67
|
-
...
|
|
68
|
-
|
|
69
|
-
|
|
155
|
+
...originals.hasConfiguredAuth,
|
|
156
|
+
// The runtime signature takes a providerId string (not a model), so gate on
|
|
157
|
+
// the provider id alone: only 'mlx' may ever report configured auth.
|
|
158
|
+
value(providerId) {
|
|
159
|
+
return providerId === MLX_PROVIDER_ID && hasConfiguredAuth.call(this, providerId);
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
checkAuth: {
|
|
163
|
+
...originals.checkAuth,
|
|
164
|
+
value(providerId) {
|
|
165
|
+
return providerId === MLX_PROVIDER_ID ? checkAuth.call(this, providerId) : Promise.resolve(undefined);
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
isUsingOAuth: {
|
|
169
|
+
...originals.isUsingOAuth,
|
|
170
|
+
value(providerId) {
|
|
171
|
+
return providerId === MLX_PROVIDER_ID && isUsingOAuth.call(this, providerId);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
getProviders: {
|
|
175
|
+
...originals.getProviders,
|
|
176
|
+
// `/login` enumerates from here; hide every cloud provider so only mlx is
|
|
177
|
+
// ever offered for sign-in.
|
|
178
|
+
value() {
|
|
179
|
+
return getProviders.call(this).filter((provider) => provider.id === MLX_PROVIDER_ID);
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
listCredentials: {
|
|
183
|
+
...originals.listCredentials,
|
|
184
|
+
// `/logout` enumerates stored credentials from here (bypassing the composed
|
|
185
|
+
// model map); never reveal a non-mlx credential.
|
|
186
|
+
value() {
|
|
187
|
+
return listCredentials.call(this).then((creds) => creds.filter((cred) => cred.providerId === MLX_PROVIDER_ID));
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
getProviderAuthStatus: {
|
|
191
|
+
...originals.getProviderAuthStatus,
|
|
192
|
+
// Reads the raw credential/config layer (not the model map); only mlx may
|
|
193
|
+
// report configured.
|
|
194
|
+
value(providerId) {
|
|
195
|
+
return providerId === MLX_PROVIDER_ID ? getProviderAuthStatus.call(this, providerId) : { configured: false };
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
getAuth: {
|
|
199
|
+
...originals.getAuth,
|
|
200
|
+
// Pivotal offline gate + reserved-id invariant. `/login`, `/logout`, and the
|
|
201
|
+
// built-in `/llama` command resolve auth through the runtime's `getAuth`,
|
|
202
|
+
// whose OAuth / `LLAMA_BASE_URL` fetch ignores PI_OFFLINE — so non-mlx must
|
|
203
|
+
// resolve to nothing (string form `getAuth(id)` or model form via
|
|
204
|
+
// `model.provider`). For mlx we do NOT delegate to the composed provider:
|
|
205
|
+
// a persisted `models.json` `{ oauth:'radius', baseUrl }` overlay under the
|
|
206
|
+
// `mlx` id promotes a Radius builtin that merges a radius OAuth method into
|
|
207
|
+
// the composed `mlx` auth; with a stored/expired `mlx` oauth credential the
|
|
208
|
+
// real resolution would trigger an offline OAuth refresh throw ("OAuth
|
|
209
|
+
// refresh failed for mlx") and never reach our local stream. Pin mlx to the
|
|
210
|
+
// fixed local credential instead — identical to the no-overlay case, but
|
|
211
|
+
// immune to the overlay. prepareRequest reads only auth.apiKey/baseUrl(/headers)
|
|
212
|
+
// and dispatches streamSimple on the (api-matched) local closure.
|
|
213
|
+
value(providerOrModel, _overrides) {
|
|
214
|
+
const providerId = typeof providerOrModel === 'string'
|
|
215
|
+
? providerOrModel
|
|
216
|
+
: providerOrModel?.provider;
|
|
217
|
+
if (providerId !== MLX_PROVIDER_ID)
|
|
218
|
+
return Promise.resolve(undefined);
|
|
219
|
+
return Promise.resolve({ auth: { apiKey: MLX_API_KEY, baseUrl: MLX_BASE_URL } });
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
login: {
|
|
223
|
+
...originals.login,
|
|
224
|
+
// Authoritative offline gate: reject any non-mlx login BEFORE dispatch, so
|
|
225
|
+
// the cloud OAuth `fetch` (e.g. radius.pi.dev) can never fire — even if some
|
|
226
|
+
// path passes a provider id directly, bypassing the filtered enumeration.
|
|
227
|
+
value(providerId, type, interaction) {
|
|
228
|
+
if (providerId !== MLX_PROVIDER_ID) {
|
|
229
|
+
return Promise.reject(new Error('mlx agent is offline: provider login is disabled'));
|
|
230
|
+
}
|
|
231
|
+
return login.call(this, providerId, type, interaction);
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
refresh: {
|
|
235
|
+
...originals.refresh,
|
|
236
|
+
// Force offline: pi's `refresh({ allowNetwork })` honours an explicit
|
|
237
|
+
// `true` (e.g. the `update --models` package command) even under
|
|
238
|
+
// PI_OFFLINE=1. Pin it off so no catalog fetch can escape the boundary.
|
|
239
|
+
value(options = {}) {
|
|
240
|
+
return refresh.call(this, { ...options, allowNetwork: false });
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
recomposeProvider: {
|
|
244
|
+
...originals.recomposeProvider,
|
|
245
|
+
// CHOKE POINT (structural backbone). `recomposeProvider` is the single
|
|
246
|
+
// funnel that writes a provider into the runtime's model map `this.models`
|
|
247
|
+
// (pi's `rebuildProviders` sweep of builtins/config + the extension
|
|
248
|
+
// `registerProvider`/`registerNativeProvider`/`unregisterProvider` paths).
|
|
249
|
+
// Compose ONLY `mlx`, so `this.models` is mlx-only by construction. That
|
|
250
|
+
// closes the whole class of INTERNAL `this.models.*` reads the public gates
|
|
251
|
+
// above cannot reach — most importantly `refresh`'s availability pass, which
|
|
252
|
+
// resolves a configured cloud provider's credential (executing a
|
|
253
|
+
// command-backed apiKey) with no allowNetwork gate — plus the `/llama`
|
|
254
|
+
// getAuth and catalog-refresh paths. Requires install before the runtime is
|
|
255
|
+
// created (runAgent installs before pi.main constructs it); the mlx provider
|
|
256
|
+
// registers later via its own `recomposeProvider('mlx')`, allowed through.
|
|
257
|
+
value(providerId) {
|
|
258
|
+
if (providerId !== MLX_PROVIDER_ID)
|
|
259
|
+
return;
|
|
260
|
+
recomposeProvider.call(this, providerId);
|
|
70
261
|
},
|
|
71
262
|
},
|
|
72
263
|
});
|
|
@@ -75,7 +266,7 @@ export function installMlxOnlyModelRegistryFilter(Registry, modelIds) {
|
|
|
75
266
|
return () => {
|
|
76
267
|
if (restored)
|
|
77
268
|
return;
|
|
78
|
-
Object.defineProperties(prototype,
|
|
269
|
+
Object.defineProperties(prototype, originals);
|
|
79
270
|
activePrototypes.delete(prototype);
|
|
80
271
|
restored = true;
|
|
81
272
|
};
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local model discovery for the mlx pi provider.
|
|
3
3
|
*
|
|
4
|
-
* Ports the discovery walk from
|
|
5
|
-
* `packages/
|
|
6
|
-
* and must not be imported from here; the cli copy stays untouched) and
|
|
4
|
+
* Ports the discovery walk from `@mlx-node/server/host`
|
|
5
|
+
* (`packages/server/src/host/discover.ts`; that copy stays untouched) and
|
|
7
6
|
* pairs every discovered checkpoint with a pi `ProviderModelConfig` entry
|
|
8
7
|
* ready for `pi.registerProvider('mlx', { models })`.
|
|
9
8
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/provider/models.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../../src/provider/models.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AAG3E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAGvD,6EAA6E;AAC7E,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,mBAAmB,CAAC;IAChC,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAmHD;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAuDlF"}
|
package/dist/provider/models.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local model discovery for the mlx pi provider.
|
|
3
3
|
*
|
|
4
|
-
* Ports the discovery walk from
|
|
5
|
-
* `packages/
|
|
6
|
-
* and must not be imported from here; the cli copy stays untouched) and
|
|
4
|
+
* Ports the discovery walk from `@mlx-node/server/host`
|
|
5
|
+
* (`packages/server/src/host/discover.ts`; that copy stays untouched) and
|
|
7
6
|
* pairs every discovered checkpoint with a pi `ProviderModelConfig` entry
|
|
8
7
|
* ready for `pi.registerProvider('mlx', { models })`.
|
|
9
8
|
*
|
|
@@ -32,39 +31,65 @@ const FAMILY_TRAITS = {
|
|
|
32
31
|
qwen3: { reasoning: true, fallbackContextWindow: 40960 },
|
|
33
32
|
qwen3_5: { reasoning: true, fallbackContextWindow: 262144 },
|
|
34
33
|
qwen3_5_moe: { reasoning: true, fallbackContextWindow: 262144 },
|
|
35
|
-
gemma4: {
|
|
34
|
+
gemma4: {
|
|
35
|
+
reasoning: true,
|
|
36
|
+
thinkingLevelMap: {
|
|
37
|
+
minimal: 'minimal',
|
|
38
|
+
low: null,
|
|
39
|
+
medium: null,
|
|
40
|
+
high: 'high',
|
|
41
|
+
},
|
|
42
|
+
fallbackContextWindow: 131072,
|
|
43
|
+
},
|
|
36
44
|
lfm2: { reasoning: true, fallbackContextWindow: 128000 },
|
|
37
45
|
lfm2_moe: { reasoning: true, fallbackContextWindow: 128000 },
|
|
38
46
|
};
|
|
39
47
|
function positiveInteger(value) {
|
|
40
48
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
|
|
41
49
|
}
|
|
50
|
+
function nonEmptyRecord(value) {
|
|
51
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
|
|
52
|
+
}
|
|
42
53
|
/**
|
|
43
|
-
* Read
|
|
54
|
+
* Read cheap discovery metadata from `<modelPath>/config.json`.
|
|
55
|
+
*
|
|
56
|
+
* The trained context window comes from:
|
|
44
57
|
* root `max_position_embeddings` first (qwen3, lfm2), then
|
|
45
58
|
* `text_config.max_position_embeddings` (qwen3_5, qwen3_5_moe, gemma4
|
|
46
|
-
* unified), else the family fallback.
|
|
47
|
-
*
|
|
48
|
-
*
|
|
59
|
+
* unified), else the family fallback.
|
|
60
|
+
*
|
|
61
|
+
* Image support is advertised only when a family with a native multimodal
|
|
62
|
+
* implementation carries its valid, non-empty vision marker: `vision_config`
|
|
63
|
+
* for Qwen, and either `vision_config` or `unified_vision_config` for Gemma.
|
|
64
|
+
* This lets Pi's model picker and `--list-models` expose checkpoint capability
|
|
65
|
+
* without loading weights. The first resident load remains authoritative and
|
|
66
|
+
* reconciles this optimistic config-level advertisement via
|
|
67
|
+
* `session.supportsImages()` (for example, when conversion stripped an
|
|
68
|
+
* incompatible vision tower).
|
|
69
|
+
*
|
|
70
|
+
* `detectModelType` already parsed this file, so a read/parse failure here
|
|
71
|
+
* (e.g. a racing rewrite) lands on the context fallback and text-only input
|
|
72
|
+
* instead of dropping the model or guessing a positive capability.
|
|
49
73
|
*/
|
|
50
|
-
async function
|
|
74
|
+
async function readDiscoveryMetadata(modelPath, modelType, fallbackContextWindow) {
|
|
51
75
|
try {
|
|
52
76
|
const raw = await readFile(join(modelPath, 'config.json'), 'utf-8');
|
|
53
77
|
const config = JSON.parse(raw);
|
|
54
78
|
const root = positiveInteger(config.max_position_embeddings);
|
|
55
|
-
if (root !== undefined)
|
|
56
|
-
return root;
|
|
57
79
|
const textConfig = config.text_config;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
80
|
+
const nested = nonEmptyRecord(textConfig) ? positiveInteger(textConfig.max_position_embeddings) : undefined;
|
|
81
|
+
const hasVisionConfig = nonEmptyRecord(config.vision_config);
|
|
82
|
+
const supportsImages = modelType === 'gemma4'
|
|
83
|
+
? hasVisionConfig || nonEmptyRecord(config.unified_vision_config)
|
|
84
|
+
: (modelType === 'qwen3_5' || modelType === 'qwen3_5_moe') && hasVisionConfig;
|
|
85
|
+
return {
|
|
86
|
+
contextWindow: root ?? nested ?? fallbackContextWindow,
|
|
87
|
+
supportsImages,
|
|
88
|
+
};
|
|
63
89
|
}
|
|
64
90
|
catch {
|
|
65
|
-
|
|
91
|
+
return { contextWindow: fallbackContextWindow, supportsImages: false };
|
|
66
92
|
}
|
|
67
|
-
return fallback;
|
|
68
93
|
}
|
|
69
94
|
/**
|
|
70
95
|
* Scan `modelsDir` for chat-capable model subdirectories and build their
|
|
@@ -113,14 +138,15 @@ export async function discoverMlxModels(modelsDir) {
|
|
|
113
138
|
continue;
|
|
114
139
|
}
|
|
115
140
|
const name = basename(full);
|
|
116
|
-
const contextWindow = await
|
|
141
|
+
const { contextWindow, supportsImages } = await readDiscoveryMetadata(full, modelType, traits.fallbackContextWindow);
|
|
117
142
|
out.push({
|
|
118
143
|
discovered: { name, path: full, modelType },
|
|
119
144
|
piModel: {
|
|
120
145
|
id: name,
|
|
121
146
|
name,
|
|
122
147
|
reasoning: traits.reasoning,
|
|
123
|
-
|
|
148
|
+
thinkingLevelMap: traits.thinkingLevelMap,
|
|
149
|
+
input: supportsImages ? ['text', 'image'] : ['text'],
|
|
124
150
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
125
151
|
contextWindow,
|
|
126
152
|
maxTokens: preset.maxOutputTokens,
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* layer is swallowed: there is no further recovery surface.
|
|
37
37
|
*/
|
|
38
38
|
import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from '@earendil-works/pi-ai';
|
|
39
|
-
import type { ChatSession, PerformanceMetrics } from '@mlx-node/lm';
|
|
39
|
+
import type { ChatSession, ChatStreamFinal, PerformanceMetrics } from '@mlx-node/lm';
|
|
40
40
|
import type { DiscoveredModelLike } from '../types.js';
|
|
41
41
|
/**
|
|
42
42
|
* The exact `MlxModelHost` surface the adapter consumes, kept structural
|
|
@@ -46,8 +46,12 @@ import type { DiscoveredModelLike } from '../types.js';
|
|
|
46
46
|
export interface StreamSimpleHost {
|
|
47
47
|
/** Discovery record for `modelId` (source of the `ModelType` → launch preset). */
|
|
48
48
|
modelInfo(modelId: string): DiscoveredModelLike | undefined;
|
|
49
|
-
/**
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Atomic resident selection + serialized inference closure (see `MlxModelHost`).
|
|
51
|
+
* `fn` receives a `resident` boolean: `true` when the model was already warm
|
|
52
|
+
* (reused), `false` when this turn had to load/swap it.
|
|
53
|
+
*/
|
|
54
|
+
runWithResident<T>(modelId: string, fn: (session: ChatSession, resident: boolean) => Promise<T>): Promise<T>;
|
|
51
55
|
/** Flag the resident as post-error so the next turn does a full reset (see `MlxModelHost`). */
|
|
52
56
|
markResidentDirty(modelId: string): void;
|
|
53
57
|
/** Read-and-clear the resident's post-error flag; `true` ⇒ full-reset this turn. */
|
|
@@ -57,5 +61,34 @@ export interface StreamSimpleHost {
|
|
|
57
61
|
}
|
|
58
62
|
export type PerformanceRecorder = (message: AssistantMessage, performance: PerformanceMetrics) => void;
|
|
59
63
|
export type RootCacheOwnerResolver = () => string | undefined;
|
|
60
|
-
|
|
64
|
+
/** Resolves the root session's JSONL path for the metrics-trace root correlation. */
|
|
65
|
+
export type RootSessionFileResolver = () => string | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Durable per-turn telemetry hook. Fires exactly once, only on a SUCCESSFUL
|
|
68
|
+
* native final (never on abort / error / load failure), from the one seam
|
|
69
|
+
* that sees the raw `ChatStreamFinal` alongside the per-request
|
|
70
|
+
* `options.sessionId` and the turn's minted `traceId`. Best-effort: the
|
|
71
|
+
* adapter guards the call so a throwing recorder can never break inference.
|
|
72
|
+
*/
|
|
73
|
+
export type TurnRecorder = (rec: {
|
|
74
|
+
traceId: string;
|
|
75
|
+
sessionId?: string;
|
|
76
|
+
/** Root session id/file snapshotted when this turn was submitted (see below). */
|
|
77
|
+
rootSessionId?: string;
|
|
78
|
+
rootSessionFile?: string;
|
|
79
|
+
model: string;
|
|
80
|
+
final: ChatStreamFinal;
|
|
81
|
+
/** Whole-turn wall-clock (ms): queue wait + resident selection + prefill + decode. */
|
|
82
|
+
durationMs: number;
|
|
83
|
+
/**
|
|
84
|
+
* Queue + cold-load wait (ms) BEFORE native work began this turn: the gap
|
|
85
|
+
* between turn submission and the serialized `runWithResident` callback
|
|
86
|
+
* firing (behind earlier inference and/or a model load/swap). Subtract from
|
|
87
|
+
* `durationMs` to recover execution-only time.
|
|
88
|
+
*/
|
|
89
|
+
queueMs: number;
|
|
90
|
+
/** `true` when the model was already warm/resident, `false` on a cold load/swap this turn. */
|
|
91
|
+
resident: boolean;
|
|
92
|
+
}) => void;
|
|
93
|
+
export declare function makeMlxStreamSimple(host: StreamSimpleHost, onPerformance?: PerformanceRecorder, resolveRootCacheOwner?: RootCacheOwnerResolver, onTurnRecord?: TurnRecorder, onTurnStart?: () => void, resolveRootSessionFile?: RootSessionFileResolver): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
|
|
61
94
|
//# sourceMappingURL=stream-adapter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream-adapter.d.ts","sourceRoot":"","sources":["../../src/provider/stream-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EACV,GAAG,EACH,gBAAgB,EAChB,2BAA2B,EAC3B,OAAO,EACP,KAAK,EACL,mBAAmB,EACpB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,KAAK,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"stream-adapter.d.ts","sourceRoot":"","sources":["../../src/provider/stream-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EACV,GAAG,EACH,gBAAgB,EAChB,2BAA2B,EAC3B,OAAO,EACP,KAAK,EACL,mBAAmB,EACpB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAErF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAOvD;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,kFAAkF;IAClF,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAAC;IAC5D;;;;OAIG;IACH,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC7G,+FAA+F;IAC/F,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACzC,oFAAoF;IACpF,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/C,gFAAgF;IAChF,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3C;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,kBAAkB,KAAK,IAAI,CAAC;AACvG,MAAM,MAAM,sBAAsB,GAAG,MAAM,MAAM,GAAG,SAAS,CAAC;AAC9D,qFAAqF;AACrF,MAAM,MAAM,uBAAuB,GAAG,MAAM,MAAM,GAAG,SAAS,CAAC;AAE/D;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,EAAE;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iFAAiF;IACjF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,eAAe,CAAC;IACvB,sFAAsF;IACtF,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB,8FAA8F;IAC9F,QAAQ,EAAE,OAAO,CAAC;CACnB,KAAK,IAAI,CAAC;AAqFX,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,gBAAgB,EACtB,aAAa,CAAC,EAAE,mBAAmB,EACnC,qBAAqB,CAAC,EAAE,sBAAsB,EAC9C,YAAY,CAAC,EAAE,YAAY,EAC3B,WAAW,CAAC,EAAE,MAAM,IAAI,EACxB,sBAAsB,CAAC,EAAE,uBAAuB,GAC/C,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,mBAAmB,KAAK,2BAA2B,CAuRrG"}
|
|
@@ -125,7 +125,7 @@ function failsafeMessage(model, reason, message) {
|
|
|
125
125
|
timestamp: Date.now(),
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
|
-
export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner) {
|
|
128
|
+
export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner, onTurnRecord, onTurnStart, resolveRootSessionFile) {
|
|
129
129
|
return (model, context, options) => {
|
|
130
130
|
const stream = createAssistantMessageEventStream();
|
|
131
131
|
/**
|
|
@@ -138,6 +138,7 @@ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner)
|
|
|
138
138
|
let emitter;
|
|
139
139
|
let signal;
|
|
140
140
|
let rootCacheOwnerId;
|
|
141
|
+
let rootSessionFile;
|
|
141
142
|
let resolvedReasoning;
|
|
142
143
|
let detachAbort;
|
|
143
144
|
/**
|
|
@@ -208,8 +209,11 @@ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner)
|
|
|
208
209
|
signal = options?.signal;
|
|
209
210
|
// Snapshot the top-level owner before this request can queue behind
|
|
210
211
|
// another inference. A later /new or /resume must not relabel an older
|
|
211
|
-
// request that was already submitted under the previous root.
|
|
212
|
+
// request that was already submitted under the previous root. The metrics
|
|
213
|
+
// root id reuses this same session_start value; only the JSONL path is a
|
|
214
|
+
// separate snapshot, taken here so it can never drift from the id.
|
|
212
215
|
rootCacheOwnerId = resolveRootCacheOwner?.();
|
|
216
|
+
rootSessionFile = resolveRootSessionFile?.();
|
|
213
217
|
// Snapshot once: the native config and the replay provenance must describe
|
|
214
218
|
// the same resolved template mode. Presence alone is wrong for Pi's
|
|
215
219
|
// minimal/low levels, both of which resolve to disabled thinking.
|
|
@@ -238,12 +242,36 @@ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner)
|
|
|
238
242
|
}
|
|
239
243
|
void (async () => {
|
|
240
244
|
let sawNativeFinal = false;
|
|
241
|
-
|
|
245
|
+
// Bracket the whole serialized turn (queue wait + resident selection +
|
|
246
|
+
// prefill + decode) so a MetricsTrace record can report wall-clock
|
|
247
|
+
// duration. Read at the terminal only on the success path below.
|
|
248
|
+
const turnStartedAt = Date.now();
|
|
249
|
+
await host.runWithResident(model.id, async (session, resident) => {
|
|
250
|
+
// Callback entry: the queue wait + any cold model load/swap have now
|
|
251
|
+
// resolved, but no native work (prime/prefill/decode) has started. This
|
|
252
|
+
// is the seam that separates queue+load latency from execution time —
|
|
253
|
+
// `durationMs` (read at the terminal) still brackets the whole turn.
|
|
254
|
+
const execStartedAt = Date.now();
|
|
255
|
+
const queueMs = Math.max(0, execStartedAt - turnStartedAt);
|
|
242
256
|
// Terminated while queued behind earlier inference/loading (or
|
|
243
257
|
// between stages below): the terminal already went out — skip ALL
|
|
244
258
|
// session work (no warm-reset, no prime, no stream).
|
|
245
259
|
if (terminated)
|
|
246
260
|
return;
|
|
261
|
+
// Serialized-turn start: fire before any native work so a metrics
|
|
262
|
+
// consumer can snapshot process-wide cold-tier counters here and diff
|
|
263
|
+
// them at the success terminal. Because turns are serialized, a prior
|
|
264
|
+
// turn that aborted/errored has already fully drained by now, so its
|
|
265
|
+
// activity is baked into this snapshot and never attributed to this
|
|
266
|
+
// turn's delta. Best-effort — a throwing hook must not break inference.
|
|
267
|
+
if (onTurnStart) {
|
|
268
|
+
try {
|
|
269
|
+
onTurnStart();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// Telemetry snapshot is best-effort; never break inference.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
247
275
|
publishEffectiveContextWindow(model, session);
|
|
248
276
|
const supportsImages = publishImageCapability(model, session);
|
|
249
277
|
const discovered = host.modelInfo(model.id);
|
|
@@ -308,6 +336,33 @@ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner)
|
|
|
308
336
|
detachAbort = undefined;
|
|
309
337
|
try {
|
|
310
338
|
turn.onFinal(event);
|
|
339
|
+
// Durable per-turn telemetry: only a successful final gets
|
|
340
|
+
// here. Guarded independently of onFinal so a throwing
|
|
341
|
+
// recorder cannot derail the (already-emitted) terminal.
|
|
342
|
+
if (onTurnRecord) {
|
|
343
|
+
try {
|
|
344
|
+
onTurnRecord({
|
|
345
|
+
traceId: turn.traceId,
|
|
346
|
+
sessionId: options?.sessionId,
|
|
347
|
+
// Root correlation is the SUBMIT-time snapshot, not the
|
|
348
|
+
// live root, so a /new or /resume that landed while this
|
|
349
|
+
// turn ran can't reattribute it (id == cacheOwner root).
|
|
350
|
+
rootSessionId: rootCacheOwnerId,
|
|
351
|
+
rootSessionFile,
|
|
352
|
+
model: model.id,
|
|
353
|
+
final: event,
|
|
354
|
+
durationMs: Math.max(0, Date.now() - turnStartedAt),
|
|
355
|
+
// Queue+load wait captured at callback entry, and the
|
|
356
|
+
// warm/cold distinction from `runWithResident` — so the
|
|
357
|
+
// record can attribute wait vs execution latency.
|
|
358
|
+
queueMs,
|
|
359
|
+
resident,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// Telemetry is best-effort; never break inference.
|
|
364
|
+
}
|
|
365
|
+
}
|
|
311
366
|
}
|
|
312
367
|
catch (err) {
|
|
313
368
|
pushFailsafeTerminal('error', coerceErrorMessage(err));
|