pi-mtplx 0.1.7 → 0.1.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/README.md CHANGED
@@ -24,8 +24,8 @@ If you pick an MTPLX model that isn't installed, the server won't start — Pi w
24
24
 
25
25
  Once installed, Pi automatically manages your MTPLX workflow:
26
26
 
27
- - **Model discovery** — `/mtplx` → **Models** scans what you've downloaded (`mtplx list`) and registers any of them with Pi. No model is bundled or pre-hardcoded: each Pi model id and its capabilities (context window, vision, reasoning) are derived live from the installed artifact, so models beyond the MTPLX stock set work too.
28
- - **Auto-start** — The MTPLX server starts when you switch to an `mtplx` model and shuts down cleanly when Pi exits.
27
+ - **Model discovery** — `/mtplx` → **Models** scans what you've downloaded (`mtplx list`) and registers any of them with Pi. Each Pi model ID comes from MTPLX's own `quickstart --dry-run` plan, while capabilities (context window, vision, reasoning) are read from the installed artifact. Models beyond the MTPLX stock set work too.
28
+ - **Auto-start** — The MTPLX server starts when you switch to an `mtplx` model. Auto shutdown is on by default and can be changed in `/mtplx`.
29
29
  - **Token speed** — A `⚡N.N tk/s` indicator appears in the footer showing the generation speed of the last assistant turn.
30
30
 
31
31
  ## Commands
@@ -35,7 +35,9 @@ Run `/mtplx` to open an interactive menu:
35
35
  | Option | What it does |
36
36
  | -------- | ------------- |
37
37
  | **Toggle (on/off)** | Start or stop the MTPLX server |
38
+ | **API Key** | View a masked identifier for, or replace, the API key Pi uses for the local MTPLX server |
38
39
  | **Fan Curves** | Set the thermal profile (`default`, `smart`, `max`) |
40
+ | **Auto Shutdown** | Choose whether Pi stops MTPLX on `/quit` or a normal terminal-close shutdown (on by default) |
39
41
  | **SSD Session Cache** | Enable or disable MTPLX's SSD-backed session cache for subsequent server starts (on by default) |
40
42
  | **Models** | Register or unregister models — ✓ means registered (click to unregister), ✗ means available (click to register) |
41
43
  | **Uninstall** | Remove the `mtplx` provider from Pi's config |
@@ -46,17 +48,17 @@ Run `/mtplx` to open an interactive menu:
46
48
 
47
49
  ### Model registry
48
50
 
49
- Models are registered in `~/.pi/agent/mtplx-models.json`. Each entry maps a Pi model ID to an MTPLX artifact ref:
51
+ Models are registered in `~/.pi/agent/mtplx-models.json`. Each entry maps MTPLX's canonical served model ID to an MTPLX artifact ref:
50
52
 
51
53
  ```json
52
54
  {
53
- "mtplx-qwen3.8-27b-mtplx-optimized-quality": {
55
+ "mtplx-qwen38-27b-optimized-quality": {
54
56
  "ref": "Youssofal/Qwen3.8-27B-MTPLX-Optimized-Quality"
55
57
  }
56
58
  }
57
59
  ```
58
60
 
59
- Register new models via the `/mtplx` → **Models** menu, or add entries manually to this file. To activate a **newly added** model, restart Pi with `/quit` and relaunch it (`pi`), then open `/model` (or `/scoped-models`) — Pi reads this file at startup, so a brand-new model id won't show up in `/model` otherwise.
61
+ Register new models via the `/mtplx` → **Models** menu; it asks MTPLX for the exact served ID. Opening this menu also migrates older ref-derived IDs to their canonical MTPLX names. Restart Pi after a migration or registration, then open `/model` (or `/scoped-models`) to activate the model.
60
62
 
61
63
  ### Fan mode
62
64
 
@@ -68,12 +70,22 @@ Controls the thermal profile of your MTPLX server. Saved to `~/.pi/agent/mtplx-f
68
70
  | `smart` | Default — adaptive thermal management |
69
71
  | `max` | Maximum fan, fastest inference |
70
72
 
73
+ ### Server authentication
74
+
75
+ pi-mtplx always uses an API key. It uses `providers.mtplx.apiKey` from `~/.pi/agent/models.json` when configured; otherwise it uses the local default `mtplx-local`. The resolved key is passed to Pi-managed MTPLX startup and sent with health, fan-control, and inference requests. If Pi already has a stored MTPLX API-key credential, pi-mtplx synchronizes it with this key at startup and whenever `/mtplx` → **API Key** saves a change. Restart Pi after changing the key so its in-memory inference provider reloads the configuration. The menu only shows a masked suffix for custom keys, never the full secret.
76
+
77
+ ### Auto shutdown
78
+
79
+ Auto shutdown is on by default. When enabled, pi-mtplx stops the managed MTPLX server when Pi exits via `/quit` or a normal terminal-close shutdown. Turn it off in `/mtplx` → **Auto Shutdown** to leave the loaded server running after Pi exits. It cannot handle abrupt termination such as `SIGKILL` or a power loss.
80
+
71
81
  ## Troubleshooting
72
82
 
73
83
  | Problem | Fix |
74
84
  | --------- | ----- |
75
85
  | **"MTPLX not started"** — Pi warns when you ask an MTPLX model to respond | Run `/mtplx` → **Toggle** to start the server |
76
86
  | **"No MTPLX models registered"** | Run `/mtplx` → **Models** to discover and register one |
87
+ | **"MTPLX is already running ... but Pi requested ..."** | A manually started MTPLX server is serving a different, unmanaged model. Stop it in the MTPLX app, or find it with `lsof -nP -iTCP:8000 -sTCP:LISTEN` and run `kill -TERM <PID>`, then retry so Pi can start and manage the selected model. |
88
+ | **"MTPLX rejected the API key"** | The running server requires a different key. Use `/mtplx` → **API Key** to enter its current key, then retry. |
77
89
  | **MTPLX startup timed out after 180s** | Run `mtplx status --deep` for MTPLX-side diagnostics (model validation, memory, thermal) |
78
90
 
79
91
  ## License
@@ -7,22 +7,30 @@
7
7
  * agent_end and session_shutdown, plus the /mtplx command.
8
8
  */
9
9
  import { acquire, release, stopServer } from "../src/mtplx-process.ts";
10
- import { getFanMode, health, setFanMode, setFanModeValue } from "../src/mtplx-client.ts";
10
+ import { authenticationFailureMessage, getFanMode, health, healthProbe, setFanMode, setFanModeValue } from "../src/mtplx-client.ts";
11
11
  import { MTPLX_PROVIDER, manageModels, removeModel, removePiMtplxProvider } from "../src/model-discovery.ts";
12
- import { FAN_MODES, isMtplxModel, loadSsdSessionCache, saveFanMode, saveSsdSessionCache, type FanMode } from "../src/utils.ts";
12
+ import { FAN_MODES, isMtplxModel, loadAutoShutdown, loadMtplxApiKey, loadSsdSessionCache, maskMtplxApiKey, saveAutoShutdown, saveFanMode, saveMtplxApiKey, saveSsdSessionCache, syncMtplxStoredCredential, type FanMode } from "../src/utils.ts";
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
14
 
15
15
  export default function mtplxAutostart(pi: ExtensionAPI): void {
16
+ // Pi's runtime may already have loaded auth.json for this process, but
17
+ // syncing here ensures its next launch uses the same key as models.json.
18
+ syncMtplxStoredCredential();
19
+
16
20
  pi.registerCommand("mtplx", {
17
- description: "MTPLX — toggle the server, configure fan and SSD cache, manage models, or uninstall",
21
+ description: "MTPLX — toggle the server, configure API key, fan, shutdown, and SSD cache, manage models, or uninstall",
18
22
  handler: async (_args, ctx) => {
19
23
  const current = await health();
20
24
  const status = current ? "on" : "off";
21
25
  await ctx.ui.setStatus("mtplx", `MTPLX: ${status}`);
22
26
  const ssdSessionCache = loadSsdSessionCache();
27
+ const autoShutdown = loadAutoShutdown();
28
+ const apiKeyLabel = maskMtplxApiKey(loadMtplxApiKey());
23
29
  const topChoices = [
24
30
  `Toggle (${status})`,
31
+ `API Key (current: ${apiKeyLabel})`,
25
32
  `Fan Curves (current: ${getFanMode()})`,
33
+ `Auto Shutdown (current: ${autoShutdown ? "on" : "off"})`,
26
34
  `SSD Session Cache (current: ${ssdSessionCache ? "on" : "off"})`,
27
35
  "Models",
28
36
  "Uninstall (remove provider)",
@@ -41,6 +49,23 @@ export default function mtplxAutostart(pi: ExtensionAPI): void {
41
49
  }
42
50
  return;
43
51
  }
52
+ if (top.startsWith("API Key")) {
53
+ const apiKey = await ctx.ui.input(`MTPLX API Key — current: ${apiKeyLabel}`, "Paste a custom key, or leave blank for the default (visible while typing)");
54
+ if (apiKey === undefined) return;
55
+ if (!saveMtplxApiKey(apiKey)) {
56
+ ctx.ui.notify("MTPLX API key was not updated. Check that models.json is valid.", "error");
57
+ return;
58
+ }
59
+ const probe = await healthProbe();
60
+ if (probe.health) {
61
+ ctx.ui.notify("MTPLX API key updated and verified against the running server. Restart Pi before inference requests use the new key.", "info");
62
+ } else if (probe.authenticationRejected) {
63
+ ctx.ui.notify(`MTPLX API key was saved, but verification failed: ${authenticationFailureMessage()} Restart Pi after correcting it.`, "error");
64
+ } else {
65
+ ctx.ui.notify("MTPLX API key updated. No running server was available to verify it. Restart Pi before inference requests use the new key.", "warning");
66
+ }
67
+ return;
68
+ }
44
69
  if (top.startsWith("Fan Curves")) {
45
70
  const choices = FAN_MODES.map((mode) => (mode === getFanMode() ? `${mode} (current)` : mode));
46
71
  const picked = await ctx.ui.select("MTPLX — fan mode for autostart", choices, undefined);
@@ -54,6 +79,17 @@ export default function mtplxAutostart(pi: ExtensionAPI): void {
54
79
  }
55
80
  ctx.ui.notify(`MTPLX fan mode set to ${getFanMode()}`, "info");
56
81
  }
82
+ if (top.startsWith("Auto Shutdown")) {
83
+ const choices = [
84
+ autoShutdown ? "Off" : "Off (current)",
85
+ autoShutdown ? "On (current)" : "On",
86
+ ];
87
+ const picked = await ctx.ui.select("MTPLX — stop the server when Pi exits", choices, undefined);
88
+ if (!picked) return;
89
+ const enabled = picked.startsWith("On");
90
+ saveAutoShutdown(enabled);
91
+ ctx.ui.notify(`MTPLX will ${enabled ? "stop automatically" : "keep running"} when Pi exits.`, "info");
92
+ }
57
93
  if (top.startsWith("SSD Session Cache")) {
58
94
  const choices = [
59
95
  ssdSessionCache ? "Off" : "Off (current)",
@@ -98,11 +134,11 @@ export default function mtplxAutostart(pi: ExtensionAPI): void {
98
134
  });
99
135
 
100
136
  pi.on("session_shutdown", async (event) => {
101
- if (event.reason !== "quit") return;
137
+ if (event.reason !== "quit" || !loadAutoShutdown()) return;
102
138
  try {
103
139
  await stopServer();
104
140
  } catch (error) {
105
- console.error(`MTPLX cleanup on quit failed: ${error instanceof Error ? error.message : String(error)}`);
141
+ console.error(`MTPLX auto-shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
106
142
  }
107
143
  });
108
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mtplx",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Zero-config MTPLX integration for Pi coding agent",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,20 +1,18 @@
1
1
  /**
2
2
  * MTPLX model registry: Pi model id → installed MTPLX artifact ref.
3
3
  *
4
- * The refs are the artifact identifiers reported by `mtplx models --json`;
5
- * `--model-id` makes /health and /v1/models report Pi's id.
4
+ * The refs are the artifact identifiers reported by `mtplx list --json`.
5
+ * MTPLX's `quickstart --dry-run --json` supplies the canonical served id used
6
+ * by Pi, /health, and /v1/models.
6
7
  *
7
8
  * The extension ships no model weights and hard-codes no model. The registry
8
9
  * holds only models registered through `/mtplx`, persisted to
9
- * ~/.pi/agent/mtplx-models.json; each Pi model id is derived live from the
10
- * artifact's ref (see modelIdFromRef) so models.json, enabledModels and this
11
- * registry always agree on the id.
10
+ * ~/.pi/agent/mtplx-models.json; models.json, enabledModels and this registry
11
+ * use the same MTPLX-supplied id.
12
12
  *
13
- * Pi's model catalog is the USER's own ~/.pi/agent/models.json — a provider
14
- * config that pre-existed this package (created by `mtplx start pi` / the
15
- * /mtplx UI). The canonical provider name is MTPLX's own `mtplx`
16
- * (PI_PROVIDER_ID in MTPLX's mtplx/pi.py); this extension reads and writes
17
- * only that provider entry and leaves every other provider untouched.
13
+ * Pi's model catalog lives in ~/.pi/agent/models.json. The canonical provider
14
+ * name is `mtplx`; this extension reads and writes only that provider entry
15
+ * and leaves every other provider untouched.
18
16
  */
19
17
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
20
18
  import { homedir } from "node:os";
@@ -22,12 +20,11 @@ import { join } from "node:path";
22
20
  import { execFile } from "node:child_process";
23
21
  import { promisify } from "node:util";
24
22
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
25
- import { modelIdFromRef, displayNameFromId } from "./utils.ts";
23
+ import { displayNameFromId } from "./utils.ts";
26
24
 
27
25
  const execFileAsync = promisify(execFile);
28
26
 
29
- // Matches MTPLX's own PI_PROVIDER_ID ("mtplx") so the extension operates on
30
- // the exact provider block that `mtplx start pi` creates.
27
+ // Keep the provider name consistent across model registration and lifecycle handling.
31
28
  export const MTPLX_PROVIDER = "mtplx";
32
29
  const MODELS_FILE = join(homedir(), ".pi", "agent", "mtplx-models.json");
33
30
  const SETTINGS_FILE = join(homedir(), ".pi", "agent", "settings.json");
@@ -101,6 +98,9 @@ export function disableModelInSettings(modelId: string): boolean {
101
98
  }
102
99
  }
103
100
  type MtplxListedModel = { repo_id?: unknown; path?: unknown; name?: unknown };
101
+ type ResolvedMtplxModel = { model: MtplxListedModel; ref: string; id: string };
102
+
103
+ const servedIdCache = new Map<string, string>();
104
104
 
105
105
  export async function listMtplxModels(): Promise<MtplxListedModel[]> {
106
106
  try {
@@ -117,6 +117,28 @@ export function listedIdentity(model: MtplxListedModel): string {
117
117
  return typeof model.repo_id === "string" && model.repo_id ? model.repo_id : typeof model.path === "string" ? model.path : "";
118
118
  }
119
119
 
120
+ /**
121
+ * MTPLX itself chooses the default served id. `mtplx list` reports artifact
122
+ * refs, not that id, so ask quickstart for its dry-run plan rather than trying
123
+ * to reproduce its naming rules in Pi.
124
+ */
125
+ export async function servedModelId(ref: string): Promise<string> {
126
+ const cached = servedIdCache.get(ref);
127
+ if (cached) return cached;
128
+ const { stdout } = await execFileAsync("mtplx", ["quickstart", "--model", ref, "--dry-run", "--json"], { timeout: 30_000 });
129
+ const id = servedModelIdFromDryRun(JSON.parse(stdout) as unknown);
130
+ servedIdCache.set(ref, id);
131
+ return id;
132
+ }
133
+
134
+ /** Parse the model id from MTPLX's machine-readable quickstart plan. */
135
+ export function servedModelIdFromDryRun(plan: unknown): string {
136
+ if (typeof plan !== "object" || plan === null || typeof (plan as { model_id?: unknown }).model_id !== "string" || !(plan as { model_id: string }).model_id) {
137
+ throw new Error("MTPLX did not return a served model id");
138
+ }
139
+ return (plan as { model_id: string }).model_id;
140
+ }
141
+
120
142
  /**
121
143
  * Model ids currently present in the `mtplx` provider of Pi's catalog
122
144
  * (models.json). This is the source of truth for whether a model is actually
@@ -135,6 +157,68 @@ function catalogModelIds(): string[] {
135
157
  }
136
158
  }
137
159
 
160
+ /**
161
+ * Replace legacy ref-derived ids with MTPLX's own served ids. This runs only
162
+ * from the explicit Models menu, where the resulting Pi restart is expected.
163
+ */
164
+ function migrateRegisteredModelIds(models: readonly ResolvedMtplxModel[]): string[] {
165
+ const servedIds = new Map(models.map(({ ref, id }) => [ref, id]));
166
+ const changes = new Map<string, string>();
167
+ for (const [oldId, entry] of Object.entries(MTPLX_MODELS)) {
168
+ const newId = servedIds.get(entry.ref);
169
+ if (!newId || newId === oldId) continue;
170
+ const collision = MTPLX_MODELS[newId];
171
+ if (collision && collision.ref !== entry.ref) {
172
+ console.warn(`pi-mtplx: cannot migrate ${oldId} to ${newId}; that id is already assigned to a different artifact.`);
173
+ continue;
174
+ }
175
+ changes.set(oldId, newId);
176
+ }
177
+ if (changes.size === 0) return [];
178
+
179
+ try {
180
+ const modelsJsonPath = join(homedir(), ".pi", "agent", "models.json");
181
+ const catalog = JSON.parse(readFileSync(modelsJsonPath, "utf8")) as { providers?: Record<string, unknown> };
182
+ const provider = catalog.providers?.[MTPLX_PROVIDER] as { models?: Record<string, unknown>[] } | undefined;
183
+ if (provider && Array.isArray(provider.models)) {
184
+ const seen = new Set<string>();
185
+ provider.models = provider.models.flatMap((model) => {
186
+ const id = typeof model.id === "string" ? model.id : undefined;
187
+ const replacement = id ? changes.get(id) : undefined;
188
+ const nextId = replacement ?? id;
189
+ if (!nextId || seen.has(nextId)) return [];
190
+ seen.add(nextId);
191
+ return [{ ...model, id: nextId, ...(replacement ? { name: displayNameFromId(nextId) } : {}) }];
192
+ });
193
+ }
194
+ writeFileSync(modelsJsonPath, JSON.stringify(catalog, null, 2) + "\n");
195
+
196
+ if (existsSync(SETTINGS_FILE)) {
197
+ const settings = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as { enabledModels?: unknown };
198
+ if (Array.isArray(settings.enabledModels)) {
199
+ settings.enabledModels = [...new Set(settings.enabledModels.map((entry) => {
200
+ if (typeof entry !== "string") return entry;
201
+ const id = entry.startsWith(`${MTPLX_PROVIDER}/`) ? entry.slice(MTPLX_PROVIDER.length + 1) : undefined;
202
+ return id && changes.has(id) ? `${MTPLX_PROVIDER}/${changes.get(id)}` : entry;
203
+ }))];
204
+ writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
205
+ }
206
+ }
207
+
208
+ for (const [oldId, newId] of changes) {
209
+ const entry = MTPLX_MODELS[oldId];
210
+ if (!entry) continue;
211
+ MTPLX_MODELS[newId] = entry;
212
+ delete MTPLX_MODELS[oldId];
213
+ }
214
+ saveRegisteredModels();
215
+ return [...changes].map(([oldId, newId]) => `${oldId} → ${newId}`);
216
+ } catch (error) {
217
+ console.error(`pi-mtplx could not migrate MTPLX model ids: ${error instanceof Error ? error.message : String(error)}`);
218
+ return [];
219
+ }
220
+ }
221
+
138
222
  type ModelProfile = {
139
223
  contextWindow: number;
140
224
  maxTokens: number;
@@ -197,13 +281,23 @@ export async function manageModels(ctx: ExtensionContext): Promise<boolean> {
197
281
  ctx.ui.notify("No MTPLX models found. Install one with `mtplx install`.", "warning");
198
282
  return false;
199
283
  }
284
+ let resolved: ResolvedMtplxModel[];
285
+ try {
286
+ resolved = [];
287
+ for (const model of installed) {
288
+ const ref = listedIdentity(model);
289
+ if (ref) resolved.push({ model, ref, id: await servedModelId(ref) });
290
+ }
291
+ } catch (error) {
292
+ ctx.ui.notify(`Could not read MTPLX's served model id: ${error instanceof Error ? error.message : String(error)}`, "error");
293
+ return false;
294
+ }
295
+ const migrated = migrateRegisteredModelIds(resolved);
296
+ if (migrated.length > 0) {
297
+ ctx.ui.notify(`Updated registered model IDs to MTPLX's canonical names. Restart Pi before selecting them.`, "info");
298
+ }
200
299
  const catalog = new Set(catalogModelIds());
201
- const choices = installed.map((model) => {
202
- const ref = listedIdentity(model);
203
- // Each artifact maps to exactly one Pi model id, derived live from its ref.
204
- // No hard-coded ids — the same id is written to models.json, enabledModels and
205
- // the registry so the three never drift apart.
206
- const id = modelIdFromRef(ref);
300
+ const choices = resolved.map(({ ref, id }) => {
207
301
  // ✓ means registered in Pi's catalog (models.json); ✗ means just installed in MTPLX.
208
302
  const mark = catalog.has(id) ? "✓" : "✗";
209
303
  return `${mark} ${id} — ${ref}`;
@@ -212,9 +306,10 @@ export async function manageModels(ctx: ExtensionContext): Promise<boolean> {
212
306
  const picked = await ctx.ui.select("MTPLX models — ✓ registered in Pi · ✗ available in MTPLX — registering a new model needs a Pi restart (/quit → pi) to show up in /model", choices, undefined);
213
307
  if (!picked || picked === "Cancel") return false;
214
308
  const ref = picked.split(" — ").slice(1).join(" — ");
215
- const modelId = modelIdFromRef(ref);
216
309
  // The installed-model record for the chosen artifact (used to read its live metadata).
217
- const chosen = installed.find((m) => listedIdentity(m) === ref);
310
+ const selected = resolved.find((entry) => entry.ref === ref);
311
+ if (!selected) return false;
312
+ const { model: chosen, id: modelId } = selected;
218
313
 
219
314
  if (catalog.has(modelId)) {
220
315
  // Unregister: remove from models.json, enabledModels, and the id→ref registry.
@@ -349,4 +444,4 @@ export function removePiMtplxProvider(): boolean {
349
444
  console.error(`pi-mtplx uninstall could not update models.json: ${error instanceof Error ? error.message : String(error)}`);
350
445
  return false;
351
446
  }
352
- }
447
+ }
@@ -4,9 +4,10 @@
4
4
  * GET /health
5
5
  * POST /v1/mtplx/thermal/fan_mode
6
6
  */
7
- import { HOST, PORT, type FanMode, loadFanMode } from "./utils.ts";
7
+ import { HOST, PORT, type FanMode, loadFanMode, loadResolvedMtplxApiKey } from "./utils.ts";
8
8
 
9
9
  export type Health = { ok: true; model: string; model_path?: string; fan_mode?: string };
10
+ export type HealthProbe = { health: Health | undefined; authenticationRejected?: true };
10
11
 
11
12
  // Current fan curve for autostart and live updates. Loaded from disk so the
12
13
  // choice survives Pi restarts (see FANMODE_FILE in utils.ts).
@@ -20,37 +21,66 @@ export function setFanModeValue(mode: FanMode): void {
20
21
  fanMode = mode;
21
22
  }
22
23
 
23
- export async function health(): Promise<Health | undefined> {
24
+ /**
25
+ * Pi's own model requests use the configured key, or pi-mtplx's deterministic
26
+ * local fallback. Mirror that header for every lifecycle endpoint.
27
+ */
28
+ function authRequest(): { headers: Record<string, string> } {
29
+ return { headers: { authorization: `Bearer ${loadResolvedMtplxApiKey()}` } };
30
+ }
31
+
32
+ export function authenticationFailureMessage(): string {
33
+ return "MTPLX rejected Pi's API key. Update it via /mtplx → API Key.";
34
+ }
35
+
36
+ export async function healthProbe(): Promise<HealthProbe> {
24
37
  const controller = new AbortController();
25
38
  const timeout = setTimeout(() => controller.abort(), 1_500);
26
39
  try {
27
- const response = await fetch(`http://${HOST}:${PORT}/health`, { signal: controller.signal });
28
- if (!response.ok) return undefined;
40
+ const auth = authRequest();
41
+ const response = await fetch(`http://${HOST}:${PORT}/health`, {
42
+ headers: auth.headers,
43
+ signal: controller.signal,
44
+ });
45
+ if (response.status === 401 || response.status === 403) {
46
+ return { health: undefined, authenticationRejected: true };
47
+ }
48
+ if (!response.ok) return { health: undefined };
29
49
  const body = (await response.json()) as { ok?: unknown; model?: unknown; model_path?: unknown; fan_mode?: unknown };
30
- if (body.ok !== true || typeof body.model !== "string") return undefined;
50
+ if (body.ok !== true || typeof body.model !== "string") return { health: undefined };
31
51
  return {
32
- ok: true,
33
- model: body.model,
34
- model_path: typeof body.model_path === "string" ? body.model_path : undefined,
35
- fan_mode: typeof body.fan_mode === "string" ? body.fan_mode : undefined,
52
+ health: {
53
+ ok: true,
54
+ model: body.model,
55
+ model_path: typeof body.model_path === "string" ? body.model_path : undefined,
56
+ fan_mode: typeof body.fan_mode === "string" ? body.fan_mode : undefined,
57
+ },
36
58
  };
37
59
  } catch {
38
- return undefined;
60
+ return { health: undefined };
39
61
  } finally {
40
62
  clearTimeout(timeout);
41
63
  }
42
64
  }
43
65
 
66
+ export async function health(): Promise<Health | undefined> {
67
+ return (await healthProbe()).health;
68
+ }
69
+
44
70
  export async function setFanMode(): Promise<void> {
45
71
  const controller = new AbortController();
46
72
  const timeout = setTimeout(() => controller.abort(), 10_000);
47
73
  try {
74
+ const auth = authRequest();
48
75
  const response = await fetch(`http://${HOST}:${PORT}/v1/mtplx/thermal/fan_mode`, {
49
76
  method: "POST",
50
- headers: { "content-type": "application/json" },
77
+ headers: { "content-type": "application/json", ...auth.headers },
51
78
  body: JSON.stringify({ mode: fanMode }),
52
79
  signal: controller.signal,
53
80
  });
81
+ if (response.status === 401 || response.status === 403) {
82
+ throw new Error(authenticationFailureMessage());
83
+ }
54
84
  const body = (await response.json()) as { verified?: unknown; current_mode?: unknown; error?: unknown };
55
85
  if (!response.ok || body.verified !== true || body.current_mode !== fanMode) {
56
86
  throw new Error(`MTPLX fan mode could not be set to ${fanMode}: ${typeof body.error === "string" ? body.error : "unverified response"}`);
@@ -60,4 +90,4 @@ export async function setFanMode(): Promise<void> {
60
90
  } finally {
61
91
  clearTimeout(timeout);
62
92
  }
63
- }
93
+ }
@@ -12,16 +12,17 @@
12
12
  * ids. A foreign service on the port is never touched, and a manually
13
13
  * started MTPLX server (no matching --model-id fingerprint) is never
14
14
  * killed — it is simply served from as-is.
15
- * - Stop goes through `mtplx stop --host --port --json` (MTPLX's own
16
- * graceful-stop mechanism: SIGTERM → grace → SIGKILL), which targets the
17
- * server answering on that exact host:port, not arbitrary processes.
15
+ * - A server launched by this Pi session is stopped through its own detached
16
+ * process group. This avoids relying on a second `mtplx` CLI invocation
17
+ * during shutdown. A positively identified server from an earlier Pi
18
+ * session still uses MTPLX's host-and-port stop command.
18
19
  */
19
20
  import { spawn, execFile } from "node:child_process";
20
21
  import { promisify } from "node:util";
21
22
  import type { ChildProcess } from "node:child_process";
22
- import { getFanMode, health, setFanMode, type Health } from "./mtplx-client.ts";
23
+ import { authenticationFailureMessage, getFanMode, health, healthProbe, setFanMode } from "./mtplx-client.ts";
23
24
  import { MTPLX_MODELS } from "./model-discovery.ts";
24
- import { HOST, PORT, READY_TIMEOUT_MS, POLL_MS, loadSsdSessionCache, sleep, commandError, portIsOccupied } from "./utils.ts";
25
+ import { HOST, PORT, READY_TIMEOUT_MS, POLL_MS, loadResolvedMtplxApiKey, loadSsdSessionCache, sleep, commandError, portIsOccupied } from "./utils.ts";
25
26
 
26
27
  const execFileAsync = promisify(execFile);
27
28
 
@@ -47,9 +48,11 @@ export function startupError(cause: Error): Error {
47
48
  * a kill target.
48
49
  */
49
50
  export async function stopServer(): Promise<void> {
50
- const current = await health();
51
+ const probe = await healthProbe();
52
+ const current = probe.health;
51
53
  if (!current) {
52
54
  if (await portIsOccupied()) {
55
+ if (probe.authenticationRejected) throw new Error(authenticationFailureMessage());
53
56
  throw new Error(`MTPLX cannot use ${HOST}:${PORT}: another, non-MTPLX service is listening there.`);
54
57
  }
55
58
  // Nothing listening (or not MTPLX): nothing to stop. Drop any stale handle.
@@ -70,19 +73,52 @@ export async function stopServer(): Promise<void> {
70
73
  return;
71
74
  }
72
75
 
73
- try {
74
- await execFileAsync("mtplx", ["stop", "--host", HOST, "--port", String(PORT), "--json"], { timeout: 20_000 });
75
- } catch (error) {
76
- throw new Error(`MTPLX shutdown failed: ${commandError(error)}`);
76
+ let stopError: unknown;
77
+ const childPid = ownedChild?.pid;
78
+ let signalledOwnedProcess = false;
79
+ if (isOwnedByThisSession() && childPid) {
80
+ try {
81
+ // `detached: true` gives this child its own POSIX process group. Signal
82
+ // that group so a quickstart wrapper and its server are stopped together.
83
+ process.kill(-childPid, "SIGTERM");
84
+ signalledOwnedProcess = true;
85
+ } catch (error) {
86
+ // The child may have already exited while its server survived. In that
87
+ // case, fall through to MTPLX's host-and-port stop command below.
88
+ if (!(error && typeof error === "object" && "code" in error && error.code === "ESRCH")) {
89
+ stopError = error;
90
+ }
91
+ }
92
+ }
93
+
94
+ if (!isOwnedByThisSession() && await signalMtplxListener()) {
95
+ signalledOwnedProcess = true;
96
+ }
97
+
98
+ if (!signalledOwnedProcess) {
99
+ try {
100
+ await execFileAsync("mtplx", ["stop", "--host", HOST, "--port", String(PORT), "--json"], { timeout: 20_000 });
101
+ stopError = undefined;
102
+ } catch (error) {
103
+ // Some MTPLX CLI failures occur after it has already signalled the server.
104
+ // Confirm the listener state before reporting shutdown as failed.
105
+ stopError = error;
106
+ }
77
107
  }
78
- ownedChild = undefined;
79
108
 
80
109
  const deadline = Date.now() + 20_000;
81
110
  while (Date.now() < deadline) {
82
- if (!(await health())) return;
111
+ if (!(await portIsOccupied())) {
112
+ ownedChild = undefined;
113
+ return;
114
+ }
83
115
  await sleep(POLL_MS);
84
116
  }
85
- throw new Error(`MTPLX shutdown timed out; ${HOST}:${PORT} is still healthy.`);
117
+ if (stopError) throw new Error(`MTPLX shutdown failed: ${commandError(stopError)}`);
118
+ if (signalledOwnedProcess) {
119
+ throw new Error(`MTPLX shutdown timed out after signalling Pi's server process; ${HOST}:${PORT} is still occupied.`);
120
+ }
121
+ throw new Error(`MTPLX shutdown timed out; ${HOST}:${PORT} is still occupied.`);
86
122
  }
87
123
 
88
124
  /** Positive ownership fingerprint: /health model id belongs to this extension's registry. */
@@ -90,6 +126,34 @@ function currentModelIsOurs(model: string): boolean {
90
126
  return Object.keys(MTPLX_MODELS).includes(model);
91
127
  }
92
128
 
129
+ /**
130
+ * Signal the process listening on the managed MTPLX port. This is used only
131
+ * after an authenticated health check and a pi-mtplx model fingerprint have
132
+ * proved the listener is one we are allowed to stop. It covers a server that
133
+ * a previous Pi session left running, which has no child handle in this one.
134
+ */
135
+ async function signalMtplxListener(): Promise<boolean> {
136
+ try {
137
+ const { stdout } = await execFileAsync("lsof", ["-t", "-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN"], { timeout: 5_000 });
138
+ const pids = stdout
139
+ .split(/\s+/)
140
+ .map(Number)
141
+ .filter((pid) => Number.isSafeInteger(pid) && pid > 0);
142
+ if (pids.length === 0) return false;
143
+ for (const pid of pids) process.kill(pid, "SIGTERM");
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function unmanagedServerModelError(runningModel: string, requestedModel: string): Error {
151
+ return new Error(
152
+ `MTPLX is already running on ${HOST}:${PORT} with model ${JSON.stringify(runningModel)}, but Pi requested ${JSON.stringify(requestedModel)}. ` +
153
+ `Pi will not replace this server because it is not managed by pi-mtplx. Stop it from the MTPLX app, or identify its listener with \`lsof -nP -iTCP:${PORT} -sTCP:LISTEN\` and run \`kill -TERM <PID>\`; then retry so Pi can start and manage the requested model.`,
154
+ );
155
+ }
156
+
93
157
  /**
94
158
  * Spawn the MTPLX server for a registered model and wait until /health
95
159
  * confirms it serves exactly that model. The child is spawned detached so it
@@ -101,27 +165,28 @@ export async function startServer(modelId: string): Promise<void> {
101
165
  throw new Error(`MTPLX model ${JSON.stringify(modelId)} is not mapped to an installed MTPLX artifact. Update the pi-mtplx model registry after adding it to Pi.`);
102
166
  }
103
167
 
168
+ const args = [
169
+ "quickstart",
170
+ "--model",
171
+ configured.ref,
172
+ "--model-id",
173
+ modelId,
174
+ "--fan-mode",
175
+ getFanMode(),
176
+ "--host",
177
+ HOST,
178
+ "--port",
179
+ String(PORT),
180
+ // Explicitly pass the user's persisted choice; this extension defaults it to on.
181
+ "--ssd-session-cache",
182
+ loadSsdSessionCache() ? "on" : "off",
183
+ ];
184
+ args.push("--api-key", loadResolvedMtplxApiKey());
185
+
104
186
  let exited: Error | undefined;
105
187
  const child = spawn(
106
188
  "mtplx",
107
- [
108
- "quickstart",
109
- "--model",
110
- configured.ref,
111
- "--model-id",
112
- modelId,
113
- "--profile",
114
- "sustained",
115
- "--fan-mode",
116
- getFanMode(),
117
- "--host",
118
- HOST,
119
- "--port",
120
- String(PORT),
121
- // Explicitly pass the user's persisted choice; this extension defaults it to on.
122
- "--ssd-session-cache",
123
- loadSsdSessionCache() ? "on" : "off",
124
- ],
189
+ args,
125
190
  { detached: true, stdio: "ignore" },
126
191
  );
127
192
  ownedChild = child;
@@ -152,13 +217,20 @@ export async function startServer(modelId: string): Promise<void> {
152
217
  * models: stop the current (identified) server, then start the requested one.
153
218
  */
154
219
  export async function ensureServer(modelId: string): Promise<void> {
155
- const current = await health();
220
+ const probe = await healthProbe();
221
+ const current = probe.health;
156
222
  if (current?.model === modelId) {
157
223
  if (current.fan_mode !== getFanMode()) await setFanMode();
158
224
  return;
159
225
  }
160
- if (current) await stopServer();
226
+ if (current) {
227
+ if (!isOwnedByThisSession() && !currentModelIsOurs(current.model)) {
228
+ throw unmanagedServerModelError(current.model, modelId);
229
+ }
230
+ await stopServer();
231
+ }
161
232
  else if (await portIsOccupied()) {
233
+ if (probe.authenticationRejected) throw new Error(authenticationFailureMessage());
162
234
  throw new Error(`MTPLX cannot start because ${HOST}:${PORT} is occupied by a non-MTPLX service.`);
163
235
  }
164
236
  await startServer(modelId);
package/src/utils.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared constants and small helpers for the pi-mtplx extension.
3
3
  */
4
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
5
  import { createConnection } from "node:net";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
@@ -10,10 +10,14 @@ export const HOST = "127.0.0.1";
10
10
  export const PORT = 8000;
11
11
  export const READY_TIMEOUT_MS = 180_000;
12
12
  export const POLL_MS = 500;
13
+ export const PI_MODELS_FILE = join(homedir(), ".pi", "agent", "models.json");
14
+ export const PI_AUTH_FILE = join(homedir(), ".pi", "agent", "auth.json");
13
15
 
14
16
  export type FanMode = "default" | "smart" | "max";
15
17
  export const FAN_MODES: readonly FanMode[] = ["default", "smart", "max"];
16
18
  export const DEFAULT_SSD_SESSION_CACHE = true;
19
+ export const DEFAULT_AUTO_SHUTDOWN = true;
20
+ export const DEFAULT_MTPLX_API_KEY = "mtplx-local";
17
21
 
18
22
  // Fan mode ("fan curve") applied at autostart, live-updated from `/mtplx` while the
19
23
  // server runs. Persisted to disk so the choice survives Pi restarts: `fanMode` is a
@@ -66,17 +70,137 @@ export function saveSsdSessionCache(enabled: boolean): void {
66
70
  }
67
71
  }
68
72
 
69
- // Derive Pi's model id from an artifact ref: "Youssofal/Qwen3.8-27B-MTPLX-Optimized-Quality"
70
- // → "mtplx-qwen38-27b-optimized-quality" (same scheme as the built-in entry).
71
- export function modelIdFromRef(ref: string): string {
72
- const slug = ref
73
- .split("/")
74
- .pop() ?? ref
75
- .replace(/^mtplx/i, "")
76
- .replace(/[^a-zA-Z0-9]+/g, "-")
77
- .replace(/-+/g, "-")
78
- .replace(/^-|-$/g, "");
79
- return `mtplx-${slug || "model"}`.toLowerCase();
73
+ // Whether Pi should stop the server during a normal process shutdown. Kept
74
+ // separate from the server's own settings so users can deliberately leave a
75
+ // loaded model available after Pi exits.
76
+ export const AUTO_SHUTDOWN_FILE = join(homedir(), ".pi", "agent", "mtplx-auto-shutdown.json");
77
+
78
+ export function loadAutoShutdown(): boolean {
79
+ try {
80
+ const parsed = JSON.parse(readFileSync(AUTO_SHUTDOWN_FILE, "utf8")) as { enabled?: unknown };
81
+ if (typeof parsed.enabled === "boolean") return parsed.enabled;
82
+ } catch {
83
+ // missing or corrupt file → default to cleaning up the Pi-managed server
84
+ }
85
+ return DEFAULT_AUTO_SHUTDOWN;
86
+ }
87
+
88
+ export function saveAutoShutdown(enabled: boolean): void {
89
+ try {
90
+ mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
91
+ writeFileSync(AUTO_SHUTDOWN_FILE, JSON.stringify({ enabled }, null, 2) + "\n");
92
+ } catch (error) {
93
+ console.error(`MTPLX could not persist auto-shutdown preference: ${error instanceof Error ? error.message : String(error)}`);
94
+ }
95
+ }
96
+
97
+ /** Read the provider-level key Pi uses for requests to the local MTPLX server. */
98
+ export function mtplxApiKeyFromCatalog(catalog: unknown): string | undefined {
99
+ if (typeof catalog !== "object" || catalog === null) return undefined;
100
+ const providers = (catalog as { providers?: unknown }).providers;
101
+ if (typeof providers !== "object" || providers === null) return undefined;
102
+ const provider = (providers as { mtplx?: unknown }).mtplx;
103
+ if (typeof provider !== "object" || provider === null) return undefined;
104
+ const apiKey = (provider as { apiKey?: unknown }).apiKey;
105
+ return typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : undefined;
106
+ }
107
+
108
+ /**
109
+ * Lifecycle calls reread this on every request. Pi's inference provider has
110
+ * its own in-memory catalog, so a key changed here still requires a Pi restart
111
+ * before model requests use it.
112
+ */
113
+ export function loadMtplxApiKey(): string | undefined {
114
+ try {
115
+ return mtplxApiKeyFromCatalog(JSON.parse(readFileSync(PI_MODELS_FILE, "utf8")) as unknown);
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /** Every Pi-managed MTPLX connection has a deterministic key. */
122
+ export function resolveMtplxApiKey(apiKey: string | undefined): string {
123
+ return apiKey ?? DEFAULT_MTPLX_API_KEY;
124
+ }
125
+
126
+ export function loadResolvedMtplxApiKey(): string {
127
+ return resolveMtplxApiKey(loadMtplxApiKey());
128
+ }
129
+
130
+ /** A stable, non-secret identifier suitable for the interactive menu. */
131
+ export function maskMtplxApiKey(apiKey: string | undefined): string {
132
+ if (!apiKey) return `default (${DEFAULT_MTPLX_API_KEY})`;
133
+ return apiKey.length <= 4 ? "configured" : `••••${apiKey.slice(-4)}`;
134
+ }
135
+
136
+ /**
137
+ * Pi gives a stored `auth.json` API-key credential precedence over models.json.
138
+ * Keep an existing MTPLX credential in sync; do not create one, because the
139
+ * provider-level key is sufficient when no stored credential exists.
140
+ */
141
+ function syncStoredMtplxCredential(apiKey: string): void {
142
+ if (!existsSync(PI_AUTH_FILE)) return;
143
+ const parsed = JSON.parse(readFileSync(PI_AUTH_FILE, "utf8")) as Record<string, unknown>;
144
+ // Current Pi stores credentials directly as { "mtplx": { ... } }. Accept
145
+ // the older nested shape too, without changing either file's structure.
146
+ const credentials = typeof parsed.auth === "object" && parsed.auth !== null && !Array.isArray(parsed.auth)
147
+ ? parsed.auth as Record<string, unknown>
148
+ : parsed;
149
+ const credential = credentials.mtplx;
150
+ if (credential === undefined) return;
151
+ if (typeof credential !== "object" || credential === null || Array.isArray(credential) || (credential as { type?: unknown }).type !== "api_key") {
152
+ throw new Error("auth.json contains an MTPLX credential that is not an API key");
153
+ }
154
+ (credential as Record<string, unknown>).key = apiKey;
155
+ writeFileSync(PI_AUTH_FILE, JSON.stringify(parsed, null, 2) + "\n");
156
+ }
157
+
158
+ /** Reconcile Pi's stored MTPLX credential with the configured provider key. */
159
+ export function syncMtplxStoredCredential(): void {
160
+ try {
161
+ syncStoredMtplxCredential(loadResolvedMtplxApiKey());
162
+ } catch (error) {
163
+ console.error(`MTPLX could not synchronize Pi's stored API key: ${error instanceof Error ? error.message : String(error)}`);
164
+ }
165
+ }
166
+
167
+ /** Update Pi's MTPLX provider and any stored MTPLX credential to the same key. */
168
+ export function saveMtplxApiKey(apiKey: string): boolean {
169
+ const normalized = apiKey.trim();
170
+ const resolved = resolveMtplxApiKey(normalized || undefined);
171
+ try {
172
+ let catalog: Record<string, unknown> = {};
173
+ if (existsSync(PI_MODELS_FILE)) {
174
+ const parsed = JSON.parse(readFileSync(PI_MODELS_FILE, "utf8")) as unknown;
175
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("models.json is not an object");
176
+ catalog = parsed as Record<string, unknown>;
177
+ }
178
+ let providers = catalog.providers;
179
+ if (providers !== undefined && (typeof providers !== "object" || providers === null || Array.isArray(providers))) {
180
+ throw new Error("models.json providers is not an object");
181
+ }
182
+ const providerCatalog = (providers ??= {}) as Record<string, unknown>;
183
+ const existing = providerCatalog.mtplx;
184
+ if (existing !== undefined && (typeof existing !== "object" || existing === null || Array.isArray(existing))) {
185
+ throw new Error("models.json mtplx provider is not an object");
186
+ }
187
+ const provider = (existing ?? {
188
+ api: "openai-completions",
189
+ authHeader: true,
190
+ baseUrl: `http://${HOST}:${PORT}/v1`,
191
+ }) as Record<string, unknown>;
192
+ if (normalized) provider.apiKey = normalized;
193
+ else delete provider.apiKey;
194
+ providerCatalog.mtplx = provider;
195
+ catalog.providers = providerCatalog;
196
+ mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
197
+ writeFileSync(PI_MODELS_FILE, JSON.stringify(catalog, null, 2) + "\n");
198
+ syncStoredMtplxCredential(resolved);
199
+ return true;
200
+ } catch (error) {
201
+ console.error(`MTPLX could not update API key: ${error instanceof Error ? error.message : String(error)}`);
202
+ return false;
203
+ }
80
204
  }
81
205
 
82
206
  export function displayNameFromId(id: string): string {
@@ -86,14 +210,6 @@ export function displayNameFromId(id: string): string {
86
210
  .replace(/\b\w/g, (c) => c.toUpperCase());
87
211
  }
88
212
 
89
- export function slugFromId(id: string): string {
90
- return id
91
- .replace(/^mtplx-/, "")
92
- .replace(/[^a-zA-Z0-9]+/g, "-")
93
- .replace(/-+/g, "-")
94
- .replace(/^-|-$/g, "");
95
- }
96
-
97
213
  export function isMtplxModel(model: { provider: string; id: string } | undefined): boolean {
98
214
  // The provider name is MTPLX's own PI_PROVIDER_ID (`mtplx`), matching the
99
215
  // provider block that `mtplx start pi` writes to models.json.