@mlx-node/agent 0.0.12 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog.d.ts +10 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +11 -2
- package/dist/delegate.d.ts +29 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +106 -0
- package/dist/extensions/delegation.d.ts +15 -0
- package/dist/extensions/delegation.d.ts.map +1 -0
- package/dist/extensions/delegation.js +93 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +16 -0
- package/dist/provider/chat-config.d.ts +6 -5
- package/dist/provider/chat-config.d.ts.map +1 -1
- package/dist/provider/chat-config.js +21 -7
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +8 -1
- package/dist/provider/model-host.d.ts +1 -1
- package/dist/provider/model-host.d.ts.map +1 -1
- package/dist/provider/model-host.js +25 -7
- package/dist/provider/models.d.ts +3 -14
- package/dist/provider/models.d.ts.map +1 -1
- package/dist/provider/models.js +17 -239
- package/dist/provider/stream-adapter.d.ts +2 -2
- package/dist/provider/stream-adapter.d.ts.map +1 -1
- package/dist/provider/stream-adapter.js +8 -5
- package/dist/run-agent.d.ts +4 -0
- package/dist/run-agent.d.ts.map +1 -1
- package/dist/run-agent.js +8 -2
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +23 -5
- package/src/catalog.ts +194 -0
- package/src/cold-tier.ts +152 -0
- package/src/delegate.ts +136 -0
- package/src/extensions/approval-detail.ts +57 -0
- package/src/extensions/delegation.ts +109 -0
- package/src/extensions/local-image-input.ts +132 -0
- package/src/extensions/permission-gate.ts +347 -0
- package/src/extensions/subagent.ts +743 -0
- package/src/extensions/terminal-title.ts +53 -0
- package/src/extensions/trace-notice.ts +37 -0
- package/src/index.ts +23 -0
- package/src/paths.ts +36 -0
- package/src/provider/chat-config.ts +132 -0
- package/src/provider/convert-messages.ts +273 -0
- package/src/provider/error-coercion.ts +36 -0
- package/src/provider/events.ts +341 -0
- package/src/provider/index.ts +255 -0
- package/src/provider/metrics-trace.ts +380 -0
- package/src/provider/mlx-identity.ts +16 -0
- package/src/provider/model-host.ts +276 -0
- package/src/provider/model-registry-filter.ts +336 -0
- package/src/provider/models.ts +48 -0
- package/src/provider/performance-status.ts +112 -0
- package/src/provider/reasoning-tag-buffer.ts +67 -0
- package/src/provider/stream-adapter.ts +515 -0
- package/src/provider/tool-call-buffer.ts +82 -0
- package/src/provider/warm-reuse.ts +125 -0
- package/src/run-agent.ts +178 -0
- package/src/types.ts +10 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local policy adapter for pi's canonical `ModelRuntime`.
|
|
3
|
+
*
|
|
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).
|
|
41
|
+
*
|
|
42
|
+
* Keep this adapter isolated: once pi exposes a first-class provider allowlist
|
|
43
|
+
* in `MainOptions`, this file can be replaced by that option without touching
|
|
44
|
+
* the provider or CLI layers.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { MLX_API, MLX_API_KEY, MLX_BASE_URL, MLX_PROVIDER_ID } from './mlx-identity.js';
|
|
48
|
+
|
|
49
|
+
interface RuntimeModel {
|
|
50
|
+
provider: string;
|
|
51
|
+
id: string;
|
|
52
|
+
api: string;
|
|
53
|
+
baseUrl: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Minimal shape of a pi `Provider` — only the id is needed to gate on provider. */
|
|
57
|
+
interface RuntimeProvider {
|
|
58
|
+
id: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Minimal shape of a pi `CredentialInfo` — only the provider id is needed. */
|
|
62
|
+
interface RuntimeCredential {
|
|
63
|
+
providerId: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Minimal shape of a pi `AuthStatus` — only the configured flag is asserted. */
|
|
67
|
+
interface RuntimeAuthStatus {
|
|
68
|
+
configured: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface FilterableModelRuntime<TModel extends RuntimeModel = RuntimeModel> {
|
|
72
|
+
getModels(providerId?: string): readonly TModel[];
|
|
73
|
+
getAvailableSnapshot(): readonly TModel[];
|
|
74
|
+
getAvailable(providerId?: string): Promise<readonly TModel[]>;
|
|
75
|
+
getModel(provider: string, modelId: string): TModel | undefined;
|
|
76
|
+
getProvider(providerId: string): RuntimeProvider | undefined;
|
|
77
|
+
hasConfiguredAuth(providerId: string): boolean;
|
|
78
|
+
checkAuth(providerId: string): Promise<unknown>;
|
|
79
|
+
isUsingOAuth(providerId: string): boolean;
|
|
80
|
+
getProviders(): readonly RuntimeProvider[];
|
|
81
|
+
listCredentials(): Promise<readonly RuntimeCredential[]>;
|
|
82
|
+
getProviderAuthStatus(providerId: string): RuntimeAuthStatus;
|
|
83
|
+
login(providerId: string, type: unknown, interaction: unknown): Promise<unknown>;
|
|
84
|
+
refresh(options?: { allowNetwork?: boolean; force?: boolean; signal?: unknown }): Promise<unknown>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface FilterableModelRuntimeConstructor<TModel extends RuntimeModel = RuntimeModel> {
|
|
88
|
+
prototype: FilterableModelRuntime<TModel>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const activePrototypes = new WeakSet<object>();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `getAuth` is intentionally NOT part of {@link FilterableModelRuntime}: pi types
|
|
95
|
+
* it with two overloads (`getAuth(providerId)` and `getAuth(model)`), which a
|
|
96
|
+
* single structural signature cannot capture without breaking the runtime→
|
|
97
|
+
* interface assignment. It is wrapped by name (mlx pins a fixed resolution; the
|
|
98
|
+
* original is never delegated to).
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/** Method names wrapped through the structural interface, plus the loose `getAuth`. */
|
|
102
|
+
const STRUCTURAL_METHODS = [
|
|
103
|
+
'getModels',
|
|
104
|
+
'getAvailableSnapshot',
|
|
105
|
+
'getAvailable',
|
|
106
|
+
'getModel',
|
|
107
|
+
'getProvider',
|
|
108
|
+
'hasConfiguredAuth',
|
|
109
|
+
'checkAuth',
|
|
110
|
+
'isUsingOAuth',
|
|
111
|
+
'getProviders',
|
|
112
|
+
'listCredentials',
|
|
113
|
+
'getProviderAuthStatus',
|
|
114
|
+
'login',
|
|
115
|
+
'refresh',
|
|
116
|
+
] as const;
|
|
117
|
+
|
|
118
|
+
function requireMethodDescriptor(prototype: object, name: string): PropertyDescriptor {
|
|
119
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
|
|
120
|
+
if (!descriptor || typeof descriptor.value !== 'function' || descriptor.writable !== true) {
|
|
121
|
+
throw new Error(`mlx agent: incompatible pi ModelRuntime.${name}; expected a writable prototype method`);
|
|
122
|
+
}
|
|
123
|
+
return descriptor;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Install an exact local-model / mlx-only-provider policy for one `runAgent()`
|
|
128
|
+
* lifetime. Returns an idempotent restore callback.
|
|
129
|
+
*/
|
|
130
|
+
export function installMlxOnlyModelRegistryFilter<TModel extends RuntimeModel>(
|
|
131
|
+
Runtime: FilterableModelRuntimeConstructor<TModel>,
|
|
132
|
+
modelIds: Iterable<string>,
|
|
133
|
+
): () => void {
|
|
134
|
+
const prototype = Runtime.prototype;
|
|
135
|
+
if (activePrototypes.has(prototype)) {
|
|
136
|
+
throw new Error('mlx agent: concurrent ModelRuntime filtering in one process is not supported');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const allowedIds = new Set(modelIds);
|
|
140
|
+
const isAllowed = (model: TModel): boolean =>
|
|
141
|
+
model.provider === MLX_PROVIDER_ID &&
|
|
142
|
+
allowedIds.has(model.id) &&
|
|
143
|
+
model.api === MLX_API &&
|
|
144
|
+
model.baseUrl === MLX_BASE_URL;
|
|
145
|
+
|
|
146
|
+
// Capture every original descriptor up front (fail-closed if pi's method shape
|
|
147
|
+
// changed), so restore can put them all back verbatim.
|
|
148
|
+
const originals: Record<string, PropertyDescriptor> = {};
|
|
149
|
+
for (const name of STRUCTURAL_METHODS) originals[name] = requireMethodDescriptor(prototype, name);
|
|
150
|
+
originals.getAuth = requireMethodDescriptor(prototype, 'getAuth');
|
|
151
|
+
// `recomposeProvider` is a private funnel method; capture it by name (it is not
|
|
152
|
+
// part of the structural interface — see the choke wrapper below).
|
|
153
|
+
originals.recomposeProvider = requireMethodDescriptor(prototype, 'recomposeProvider');
|
|
154
|
+
|
|
155
|
+
const iface = originals as unknown as {
|
|
156
|
+
[K in (typeof STRUCTURAL_METHODS)[number]]: { value: FilterableModelRuntime<TModel>[K] };
|
|
157
|
+
};
|
|
158
|
+
const getModels = iface.getModels.value;
|
|
159
|
+
const getAvailableSnapshot = iface.getAvailableSnapshot.value;
|
|
160
|
+
const getAvailable = iface.getAvailable.value;
|
|
161
|
+
const getModel = iface.getModel.value;
|
|
162
|
+
const getProvider = iface.getProvider.value;
|
|
163
|
+
const hasConfiguredAuth = iface.hasConfiguredAuth.value;
|
|
164
|
+
const checkAuth = iface.checkAuth.value;
|
|
165
|
+
const isUsingOAuth = iface.isUsingOAuth.value;
|
|
166
|
+
const getProviders = iface.getProviders.value;
|
|
167
|
+
const listCredentials = iface.listCredentials.value;
|
|
168
|
+
const getProviderAuthStatus = iface.getProviderAuthStatus.value;
|
|
169
|
+
const login = iface.login.value;
|
|
170
|
+
const refresh = iface.refresh.value;
|
|
171
|
+
const recomposeProvider = originals.recomposeProvider.value as (this: object, providerId: string) => void;
|
|
172
|
+
|
|
173
|
+
Object.defineProperties(prototype, {
|
|
174
|
+
getModels: {
|
|
175
|
+
...originals.getModels,
|
|
176
|
+
value(this: FilterableModelRuntime<TModel>, providerId?: string): TModel[] {
|
|
177
|
+
return getModels.call(this, providerId).filter(isAllowed);
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
getAvailableSnapshot: {
|
|
181
|
+
...originals.getAvailableSnapshot,
|
|
182
|
+
value(this: FilterableModelRuntime<TModel>): TModel[] {
|
|
183
|
+
return getAvailableSnapshot.call(this).filter(isAllowed);
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
getAvailable: {
|
|
187
|
+
...originals.getAvailable,
|
|
188
|
+
// The runtime read is async, so filter the resolved snapshot. Preserve the
|
|
189
|
+
// Promise contract (never turn a rejection into a filtered success).
|
|
190
|
+
value(this: FilterableModelRuntime<TModel>, providerId?: string): Promise<TModel[]> {
|
|
191
|
+
return getAvailable.call(this, providerId).then((models) => models.filter(isAllowed));
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
getModel: {
|
|
195
|
+
...originals.getModel,
|
|
196
|
+
value(this: FilterableModelRuntime<TModel>, provider: string, modelId: string): TModel | undefined {
|
|
197
|
+
if (provider !== MLX_PROVIDER_ID || !allowedIds.has(modelId)) return undefined;
|
|
198
|
+
const model = getModel.call(this, provider, modelId);
|
|
199
|
+
return model && isAllowed(model) ? model : undefined;
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
getProvider: {
|
|
203
|
+
...originals.getProvider,
|
|
204
|
+
// Streaming uses `this.models.getProvider` (a different object); this only
|
|
205
|
+
// gates external reads (e.g. `/logout`). Non-mlx must never surface.
|
|
206
|
+
value(this: FilterableModelRuntime<TModel>, providerId: string): RuntimeProvider | undefined {
|
|
207
|
+
return providerId === MLX_PROVIDER_ID ? getProvider.call(this, providerId) : undefined;
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
hasConfiguredAuth: {
|
|
211
|
+
...originals.hasConfiguredAuth,
|
|
212
|
+
// The runtime signature takes a providerId string (not a model), so gate on
|
|
213
|
+
// the provider id alone: only 'mlx' may ever report configured auth.
|
|
214
|
+
value(this: FilterableModelRuntime<TModel>, providerId: string): boolean {
|
|
215
|
+
return providerId === MLX_PROVIDER_ID && hasConfiguredAuth.call(this, providerId);
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
checkAuth: {
|
|
219
|
+
...originals.checkAuth,
|
|
220
|
+
value(this: FilterableModelRuntime<TModel>, providerId: string): Promise<unknown> {
|
|
221
|
+
return providerId === MLX_PROVIDER_ID ? checkAuth.call(this, providerId) : Promise.resolve(undefined);
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
isUsingOAuth: {
|
|
225
|
+
...originals.isUsingOAuth,
|
|
226
|
+
value(this: FilterableModelRuntime<TModel>, providerId: string): boolean {
|
|
227
|
+
return providerId === MLX_PROVIDER_ID && isUsingOAuth.call(this, providerId);
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
getProviders: {
|
|
231
|
+
...originals.getProviders,
|
|
232
|
+
// `/login` enumerates from here; hide every cloud provider so only mlx is
|
|
233
|
+
// ever offered for sign-in.
|
|
234
|
+
value(this: FilterableModelRuntime<TModel>): RuntimeProvider[] {
|
|
235
|
+
return getProviders.call(this).filter((provider) => provider.id === MLX_PROVIDER_ID);
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
listCredentials: {
|
|
239
|
+
...originals.listCredentials,
|
|
240
|
+
// `/logout` enumerates stored credentials from here (bypassing the composed
|
|
241
|
+
// model map); never reveal a non-mlx credential.
|
|
242
|
+
value(this: FilterableModelRuntime<TModel>): Promise<RuntimeCredential[]> {
|
|
243
|
+
return listCredentials.call(this).then((creds) => creds.filter((cred) => cred.providerId === MLX_PROVIDER_ID));
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
getProviderAuthStatus: {
|
|
247
|
+
...originals.getProviderAuthStatus,
|
|
248
|
+
// Reads the raw credential/config layer (not the model map); only mlx may
|
|
249
|
+
// report configured.
|
|
250
|
+
value(this: FilterableModelRuntime<TModel>, providerId: string): RuntimeAuthStatus {
|
|
251
|
+
return providerId === MLX_PROVIDER_ID ? getProviderAuthStatus.call(this, providerId) : { configured: false };
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
getAuth: {
|
|
255
|
+
...originals.getAuth,
|
|
256
|
+
// Pivotal offline gate + reserved-id invariant. `/login`, `/logout`, and the
|
|
257
|
+
// built-in `/llama` command resolve auth through the runtime's `getAuth`,
|
|
258
|
+
// whose OAuth / `LLAMA_BASE_URL` fetch ignores PI_OFFLINE — so non-mlx must
|
|
259
|
+
// resolve to nothing (string form `getAuth(id)` or model form via
|
|
260
|
+
// `model.provider`). For mlx we do NOT delegate to the composed provider:
|
|
261
|
+
// a persisted `models.json` `{ oauth:'radius', baseUrl }` overlay under the
|
|
262
|
+
// `mlx` id promotes a Radius builtin that merges a radius OAuth method into
|
|
263
|
+
// the composed `mlx` auth; with a stored/expired `mlx` oauth credential the
|
|
264
|
+
// real resolution would trigger an offline OAuth refresh throw ("OAuth
|
|
265
|
+
// refresh failed for mlx") and never reach our local stream. Pin mlx to the
|
|
266
|
+
// fixed local credential instead — identical to the no-overlay case, but
|
|
267
|
+
// immune to the overlay. prepareRequest reads only auth.apiKey/baseUrl(/headers)
|
|
268
|
+
// and dispatches streamSimple on the (api-matched) local closure.
|
|
269
|
+
value(this: object, providerOrModel: unknown, _overrides?: unknown): Promise<unknown> {
|
|
270
|
+
const providerId =
|
|
271
|
+
typeof providerOrModel === 'string'
|
|
272
|
+
? providerOrModel
|
|
273
|
+
: (providerOrModel as { provider?: unknown } | null | undefined)?.provider;
|
|
274
|
+
if (providerId !== MLX_PROVIDER_ID) return Promise.resolve(undefined);
|
|
275
|
+
return Promise.resolve({ auth: { apiKey: MLX_API_KEY, baseUrl: MLX_BASE_URL } });
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
login: {
|
|
279
|
+
...originals.login,
|
|
280
|
+
// Authoritative offline gate: reject any non-mlx login BEFORE dispatch, so
|
|
281
|
+
// the cloud OAuth `fetch` (e.g. radius.pi.dev) can never fire — even if some
|
|
282
|
+
// path passes a provider id directly, bypassing the filtered enumeration.
|
|
283
|
+
value(
|
|
284
|
+
this: FilterableModelRuntime<TModel>,
|
|
285
|
+
providerId: string,
|
|
286
|
+
type: unknown,
|
|
287
|
+
interaction: unknown,
|
|
288
|
+
): Promise<unknown> {
|
|
289
|
+
if (providerId !== MLX_PROVIDER_ID) {
|
|
290
|
+
return Promise.reject(new Error('mlx agent is offline: provider login is disabled'));
|
|
291
|
+
}
|
|
292
|
+
return login.call(this, providerId, type, interaction);
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
refresh: {
|
|
296
|
+
...originals.refresh,
|
|
297
|
+
// Force offline: pi's `refresh({ allowNetwork })` honours an explicit
|
|
298
|
+
// `true` (e.g. the `update --models` package command) even under
|
|
299
|
+
// PI_OFFLINE=1. Pin it off so no catalog fetch can escape the boundary.
|
|
300
|
+
value(
|
|
301
|
+
this: FilterableModelRuntime<TModel>,
|
|
302
|
+
options: { allowNetwork?: boolean; force?: boolean; signal?: unknown } = {},
|
|
303
|
+
): Promise<unknown> {
|
|
304
|
+
return refresh.call(this, { ...options, allowNetwork: false });
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
recomposeProvider: {
|
|
308
|
+
...originals.recomposeProvider,
|
|
309
|
+
// CHOKE POINT (structural backbone). `recomposeProvider` is the single
|
|
310
|
+
// funnel that writes a provider into the runtime's model map `this.models`
|
|
311
|
+
// (pi's `rebuildProviders` sweep of builtins/config + the extension
|
|
312
|
+
// `registerProvider`/`registerNativeProvider`/`unregisterProvider` paths).
|
|
313
|
+
// Compose ONLY `mlx`, so `this.models` is mlx-only by construction. That
|
|
314
|
+
// closes the whole class of INTERNAL `this.models.*` reads the public gates
|
|
315
|
+
// above cannot reach — most importantly `refresh`'s availability pass, which
|
|
316
|
+
// resolves a configured cloud provider's credential (executing a
|
|
317
|
+
// command-backed apiKey) with no allowNetwork gate — plus the `/llama`
|
|
318
|
+
// getAuth and catalog-refresh paths. Requires install before the runtime is
|
|
319
|
+
// created (runAgent installs before pi.main constructs it); the mlx provider
|
|
320
|
+
// registers later via its own `recomposeProvider('mlx')`, allowed through.
|
|
321
|
+
value(this: object, providerId: string): void {
|
|
322
|
+
if (providerId !== MLX_PROVIDER_ID) return;
|
|
323
|
+
recomposeProvider.call(this, providerId);
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
activePrototypes.add(prototype);
|
|
329
|
+
let restored = false;
|
|
330
|
+
return () => {
|
|
331
|
+
if (restored) return;
|
|
332
|
+
Object.defineProperties(prototype, originals);
|
|
333
|
+
activePrototypes.delete(prototype);
|
|
334
|
+
restored = true;
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local model discovery for the mlx pi provider.
|
|
3
|
+
*
|
|
4
|
+
* Uses the same native-free inventory as the inference host and setup UI,
|
|
5
|
+
* pairing every discovered checkpoint
|
|
6
|
+
* with a pi `ProviderModelConfig` entry ready for
|
|
7
|
+
* `pi.registerProvider('mlx', { models })`.
|
|
8
|
+
*
|
|
9
|
+
* `contextWindow` starts as the checkpoint's trained window, read from the model dir's
|
|
10
|
+
* `config.json` `max_position_embeddings` (root first, then the
|
|
11
|
+
* `text_config` nesting used by qwen3_5 / qwen3_5_moe / gemma4 unified
|
|
12
|
+
* checkpoints). Once a Qwen or Muse-Glimmer model loads, the provider narrows
|
|
13
|
+
* this shared model metadata to the physical paged-cache window so pi's later
|
|
14
|
+
* auto-compaction thresholds match reality. When both config fields are absent
|
|
15
|
+
* the per-family fallback documented on `FamilyTraits` (`@mlx-node/lm`
|
|
16
|
+
* family-data) applies.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent';
|
|
20
|
+
import { discoverLocalChatModels } from '@mlx-node/lm/model-discovery';
|
|
21
|
+
|
|
22
|
+
import type { DiscoveredModelLike } from '../types.js';
|
|
23
|
+
|
|
24
|
+
/** A discovered local checkpoint paired with its pi provider model entry. */
|
|
25
|
+
export interface MlxModelInfo {
|
|
26
|
+
discovered: DiscoveredModelLike;
|
|
27
|
+
piModel: ProviderModelConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Pair the shared local inventory with pi provider metadata, without loading weights. */
|
|
31
|
+
export async function discoverMlxModels(modelsDir: string): Promise<MlxModelInfo[]> {
|
|
32
|
+
return (await discoverLocalChatModels(modelsDir)).map(
|
|
33
|
+
({ name, path, modelType, preset, traits, supportsImages, contextWindow }) => ({
|
|
34
|
+
discovered: { name, path, modelType },
|
|
35
|
+
piModel: {
|
|
36
|
+
id: name,
|
|
37
|
+
name,
|
|
38
|
+
reasoning: traits.reasoning,
|
|
39
|
+
// pi types are agent-only; keep the shared structural map assignable.
|
|
40
|
+
thinkingLevelMap: traits.thinkingLevelMap satisfies ProviderModelConfig['thinkingLevelMap'],
|
|
41
|
+
input: supportsImages ? ['text', 'image'] : ['text'],
|
|
42
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
43
|
+
contextWindow,
|
|
44
|
+
maxTokens: preset.maxOutputTokens,
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transient per-message inference telemetry for the interactive agent footer.
|
|
3
|
+
*
|
|
4
|
+
* Pi's `Usage` object drives token accounting and compaction, so native
|
|
5
|
+
* throughput must not be smuggled into it. The exact AssistantMessage object
|
|
6
|
+
* is delivered to extension `message_end` handlers before persistence; a
|
|
7
|
+
* provider-scoped WeakMap therefore carries the metrics to the TUI without
|
|
8
|
+
* changing the conversation schema or retaining completed messages.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AssistantMessage } from '@earendil-works/pi-ai';
|
|
12
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
13
|
+
import type { PerformanceMetrics } from '@mlx-node/lm';
|
|
14
|
+
|
|
15
|
+
const STATUS_KEY = 'mlx-performance';
|
|
16
|
+
|
|
17
|
+
interface ThroughputSample {
|
|
18
|
+
ttftMs?: number;
|
|
19
|
+
prefillTokensPerSecond: number;
|
|
20
|
+
decodeTokensPerSecond: number;
|
|
21
|
+
inputTokens?: number;
|
|
22
|
+
cachedTokens?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface MessageEndLike {
|
|
26
|
+
message: { role: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function finiteRate(value: number): number | undefined {
|
|
30
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function finiteTokenCount(value: number | undefined): number | undefined {
|
|
34
|
+
return value !== undefined && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatRate(value: number): string {
|
|
38
|
+
return value.toLocaleString('en-US', {
|
|
39
|
+
minimumFractionDigits: 1,
|
|
40
|
+
maximumFractionDigits: 1,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatDuration(ttftMs: number): string {
|
|
45
|
+
if (ttftMs < 1000) return `${formatRate(ttftMs)} ms`;
|
|
46
|
+
return `${(ttftMs / 1000).toLocaleString('en-US', {
|
|
47
|
+
minimumFractionDigits: 2,
|
|
48
|
+
maximumFractionDigits: 2,
|
|
49
|
+
})} s`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatTokenCount(value: number): string {
|
|
53
|
+
if (value < 1000) return value.toLocaleString('en-US');
|
|
54
|
+
const [divisor, suffix] = value < 1_000_000 ? [1000, 'k'] : [1_000_000, 'm'];
|
|
55
|
+
return `${(value / divisor).toLocaleString('en-US', { maximumFractionDigits: 1 })}${suffix}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatContext(sample: ThroughputSample): string {
|
|
59
|
+
let context = '';
|
|
60
|
+
if (sample.ttftMs !== undefined) context += ` · TTFT ${formatDuration(sample.ttftMs)}`;
|
|
61
|
+
if (sample.inputTokens !== undefined && sample.cachedTokens !== undefined) {
|
|
62
|
+
if (sample.cachedTokens > 0) {
|
|
63
|
+
context +=
|
|
64
|
+
` · input ${formatTokenCount(sample.inputTokens)} new` + ` + ${formatTokenCount(sample.cachedTokens)} cached`;
|
|
65
|
+
} else {
|
|
66
|
+
context += ` · input ${formatTokenCount(sample.inputTokens)}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return context;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatSample(sample: ThroughputSample): string {
|
|
73
|
+
return (
|
|
74
|
+
`mlx${formatContext(sample)}` +
|
|
75
|
+
` · prefill ${formatRate(sample.prefillTokensPerSecond)} tok/s` +
|
|
76
|
+
` · decode ${formatRate(sample.decodeTokensPerSecond)} tok/s`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class PerformanceStatus {
|
|
81
|
+
private readonly byMessage = new WeakMap<AssistantMessage, ThroughputSample>();
|
|
82
|
+
|
|
83
|
+
/** Record only complete, displayable samples; malformed native metrics are ignored. */
|
|
84
|
+
readonly record = (message: AssistantMessage, performance: PerformanceMetrics): void => {
|
|
85
|
+
const prefillTokensPerSecond = finiteRate(performance.prefillTokensPerSecond);
|
|
86
|
+
const decodeTokensPerSecond = finiteRate(performance.decodeTokensPerSecond);
|
|
87
|
+
if (prefillTokensPerSecond === undefined || decodeTokensPerSecond === undefined) return;
|
|
88
|
+
const sample: ThroughputSample = { prefillTokensPerSecond, decodeTokensPerSecond };
|
|
89
|
+
const ttftMs = finiteRate(performance.ttftMs);
|
|
90
|
+
if (ttftMs !== undefined) sample.ttftMs = ttftMs;
|
|
91
|
+
const inputTokens = finiteTokenCount(message.usage?.input);
|
|
92
|
+
const cachedTokens = finiteTokenCount(message.usage?.cacheRead);
|
|
93
|
+
if (inputTokens !== undefined && cachedTokens !== undefined) {
|
|
94
|
+
sample.inputTokens = inputTokens;
|
|
95
|
+
sample.cachedTokens = cachedTokens;
|
|
96
|
+
}
|
|
97
|
+
this.byMessage.set(message, sample);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Render the successful mlx inference associated with this exact Pi message. */
|
|
101
|
+
showMessage(event: MessageEndLike, ctx: ExtensionContext): void {
|
|
102
|
+
if (ctx.mode !== 'tui' || event.message.role !== 'assistant') return;
|
|
103
|
+
const sample = this.byMessage.get(event.message as AssistantMessage);
|
|
104
|
+
if (!sample) return;
|
|
105
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg('dim', formatSample(sample)));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Prevent the selected model's completed sample lingering after its lifecycle ends. */
|
|
109
|
+
clear(ctx: ExtensionContext): void {
|
|
110
|
+
if (ctx.mode === 'tui') ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes model-protocol thinking tags from raw native reasoning deltas.
|
|
3
|
+
*
|
|
4
|
+
* The native stream deliberately exposes raw ChatML text, so `<think>` /
|
|
5
|
+
* `</think>` (and the LongCat variants) may arrive as complete tags or split
|
|
6
|
+
* across multiple deltas. Pi's `ThinkingContent` is already structured and
|
|
7
|
+
* must contain only the reasoning body.
|
|
8
|
+
*
|
|
9
|
+
* Text that cannot belong to a partial structural tag is released
|
|
10
|
+
* immediately. An ambiguous suffix is held until another delta disambiguates
|
|
11
|
+
* it or `flush()` recovers it at a terminal boundary.
|
|
12
|
+
*/
|
|
13
|
+
export class ReasoningTagBuffer {
|
|
14
|
+
private static readonly TAGS = ['<think>', '</think>', '<longcat_think>', '</longcat_think>'] as const;
|
|
15
|
+
private pendingText = '';
|
|
16
|
+
|
|
17
|
+
/** Feed one raw reasoning delta and return protocol-tag-free text. */
|
|
18
|
+
push(text: string): string {
|
|
19
|
+
this.pendingText += text;
|
|
20
|
+
let safeText = '';
|
|
21
|
+
|
|
22
|
+
while (this.pendingText) {
|
|
23
|
+
const match = this.findFirstTag();
|
|
24
|
+
if (match) {
|
|
25
|
+
safeText += this.pendingText.slice(0, match.index);
|
|
26
|
+
this.pendingText = this.pendingText.slice(match.index + match.tag.length);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const safeLen = this.safePrefixLength();
|
|
31
|
+
safeText += this.pendingText.slice(0, safeLen);
|
|
32
|
+
this.pendingText = this.pendingText.slice(safeLen);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return safeText;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Release an incomplete, therefore non-structural, tag prefix at stream end. */
|
|
40
|
+
flush(): string {
|
|
41
|
+
const text = this.pendingText;
|
|
42
|
+
this.pendingText = '';
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private findFirstTag(): { index: number; tag: (typeof ReasoningTagBuffer.TAGS)[number] } | null {
|
|
47
|
+
let first: { index: number; tag: (typeof ReasoningTagBuffer.TAGS)[number] } | null = null;
|
|
48
|
+
for (const tag of ReasoningTagBuffer.TAGS) {
|
|
49
|
+
const index = this.pendingText.indexOf(tag);
|
|
50
|
+
if (index >= 0 && (first === null || index < first.index)) {
|
|
51
|
+
first = { index, tag };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return first;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private safePrefixLength(): number {
|
|
58
|
+
const maxTagLength = Math.max(...ReasoningTagBuffer.TAGS.map((tag) => tag.length));
|
|
59
|
+
for (let length = 1; length <= Math.min(this.pendingText.length, maxTagLength - 1); length++) {
|
|
60
|
+
const suffix = this.pendingText.slice(-length);
|
|
61
|
+
if (ReasoningTagBuffer.TAGS.some((tag) => tag.startsWith(suffix))) {
|
|
62
|
+
return this.pendingText.length - length;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return this.pendingText.length;
|
|
66
|
+
}
|
|
67
|
+
}
|