pi-mtplx 0.1.9 → 0.2.0
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 +32 -8
- package/extensions/mtplx.ts +114 -15
- package/package.json +1 -1
- package/src/model-discovery.ts +45 -16
- package/src/mtplx-client.ts +8 -8
- package/src/mtplx-process.ts +75 -76
- package/src/utils.ts +94 -2
package/README.md
CHANGED
|
@@ -18,14 +18,14 @@ Restart Pi after installation.
|
|
|
18
18
|
2. Download the model(s) you want, e.g. `mtplx install Youssofal/Qwen3.8-27B-MTPLX-Optimized-Quality`.
|
|
19
19
|
3. Register a downloaded model with Pi via `/mtplx` → **Models** (or add it manually to `~/.pi/agent/mtplx-models.json`), then open `/model` to activate it.
|
|
20
20
|
|
|
21
|
-
If you pick an MTPLX model that isn't installed,
|
|
21
|
+
`/mtplx` → **Models** only scans models installed through the local MTPLX CLI. It never downloads model weights and does not discover models available only on a configured remote endpoint. If you pick an MTPLX model that isn't installed locally, Pi cannot auto-start it. Use `mtplx list` to see what you've downloaded.
|
|
22
22
|
|
|
23
23
|
## What happens on first run
|
|
24
24
|
|
|
25
25
|
Once installed, Pi automatically manages your MTPLX workflow:
|
|
26
26
|
|
|
27
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
|
|
28
|
+
- **Auto-start** — The MTPLX server starts when you switch to an `mtplx` model. Auto-start and auto-shutdown are 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
|
|
@@ -34,12 +34,15 @@ Run `/mtplx` to open an interactive menu:
|
|
|
34
34
|
|
|
35
35
|
| Option | What it does |
|
|
36
36
|
| -------- | ------------- |
|
|
37
|
-
| **
|
|
37
|
+
| **Server** | Read-only status showing the model currently served at the configured endpoint, or that the endpoint is unavailable |
|
|
38
|
+
| **Toggle (on/off)** | Start or stop an MTPLX server launched by this Pi session; separately managed servers are left running |
|
|
38
39
|
| **API Key** | View a masked identifier for, or replace, the API key Pi uses for the local MTPLX server |
|
|
40
|
+
| **Endpoint** | Set the OpenAI-compatible MTPLX base URL; useful for a separately started server |
|
|
39
41
|
| **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) |
|
|
42
|
+
| **Auto Shutdown Pi-Owned Server** | Choose whether Pi stops an MTPLX server it launched on `/quit` or a normal terminal-close shutdown (on by default); separately managed and remote servers are always left running |
|
|
43
|
+
| **Auto Start** | Choose whether Pi starts or switches MTPLX when loading an MTPLX model (on by default) |
|
|
41
44
|
| **SSD Session Cache** | Enable or disable MTPLX's SSD-backed session cache for subsequent server starts (on by default) |
|
|
42
|
-
| **Models** | Register or unregister models — ✓ means registered (click to unregister), ✗ means available (click to register) |
|
|
45
|
+
| **Models** | Register or unregister locally installed models — ✓ means registered (click to unregister), ✗ means available (click to register); it does not fetch or discover remote models |
|
|
43
46
|
| **Uninstall** | Remove the `mtplx` provider from Pi's config |
|
|
44
47
|
|
|
45
48
|
> **To activate a model** after registering or unregistering it, open **`/model`** (or `/scoped-models`). pi-mtplx writes Pi's config files immediately, but Pi loads them into memory on startup — so a **newly registered** MTPLX model only appears in `/model` after you restart Pi with **`/quit`** and relaunch it (`pi`). Unregistering/re-registering an existing model is picked up by opening `/model`, but a brand-new model id requires the restart.
|
|
@@ -60,9 +63,11 @@ Models are registered in `~/.pi/agent/mtplx-models.json`. Each entry maps MTPLX'
|
|
|
60
63
|
|
|
61
64
|
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.
|
|
62
65
|
|
|
66
|
+
Local Forge outputs use their installed paths as artifact refs; downloaded repositories retain their `owner/model` refs. If an artifact cannot produce a serving plan, the menu shows MTPLX's error and skips that artifact while keeping the other models available.
|
|
67
|
+
|
|
63
68
|
### Fan mode
|
|
64
69
|
|
|
65
|
-
Controls the thermal profile
|
|
70
|
+
Controls the thermal profile used when Pi starts an MTPLX server. Saved to `~/.pi/agent/mtplx-fanmode.json` and persists across restarts. When Pi discovers a server that is already running—whether it is local, remote, or Pi-owned—it does not automatically change the fan curve; if the server reports a supported curve that differs from Pi's saved default, Pi adopts that curve as its new default for future Pi-started servers. Choosing `/mtplx` → **Fan Curves** is an explicit override and changes the fan curve of the active healthy server.
|
|
66
71
|
|
|
67
72
|
| Mode | Behavior |
|
|
68
73
|
| ------ | ---------- |
|
|
@@ -74,9 +79,27 @@ Controls the thermal profile of your MTPLX server. Saved to `~/.pi/agent/mtplx-f
|
|
|
74
79
|
|
|
75
80
|
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
81
|
|
|
77
|
-
###
|
|
82
|
+
### Standalone MTPLX
|
|
83
|
+
|
|
84
|
+
To use an MTPLX server that you start outside Pi, set `/mtplx` → **Auto Start** to off, then set `/mtplx` → **Endpoint** to that server's OpenAI base URL (for example, `http://127.0.0.1:8001/v1`). Restart Pi after changing it. The endpoint, health checks, and fan controls all use `providers.mtplx.baseUrl`; its default is `http://127.0.0.1:8000/v1`. pi-mtplx never stops a server it did not launch, including on normal Pi shutdown.
|
|
85
|
+
|
|
86
|
+
The served model ID must be the same ID selected in Pi. Before every MTPLX request, pi-mtplx verifies this through `/health` and reports both IDs if they differ. A separately managed server is never switched; start it with `--model-id <Pi model ID>` if its artifact's default identity differs. `/mtplx` → **Models** cannot discover a remote-only model, so add its exact ID to Pi's `mtplx` provider configuration manually before selecting it. Use the same API key in both the MTPLX launch command and `/mtplx` → **API Key**.
|
|
87
|
+
|
|
88
|
+
### Auto start
|
|
89
|
+
|
|
90
|
+
Auto Start is on by default. Pi first checks the selected endpoint before starting anything. If a healthy MTPLX server is already there and serves the selected model, Pi uses it without replacing it. If it serves a different model, Pi switches it only when that server was started by the current Pi session; separately managed and remote servers are left alone, and Pi blocks the request with both model IDs. Pi starts MTPLX only when no MTPLX server is available at a loopback endpoint. With Auto Start off, Pi performs the same health and model-match check but never starts or switches a server; it instead tells you to start MTPLX at the configured endpoint.
|
|
91
|
+
|
|
92
|
+
### Auto shutdown Pi-owned server
|
|
93
|
+
|
|
94
|
+
Auto shutdown is on by default. When enabled, pi-mtplx stops an MTPLX server it launched during the current Pi session when Pi exits via `/quit` or a normal terminal-close shutdown. It never stops a separately managed or remote server. Turn it off in `/mtplx` → **Auto Shutdown Pi-Owned Server** to leave the Pi-launched server running after Pi exits. It cannot handle abrupt termination such as `SIGKILL` or a power loss.
|
|
95
|
+
|
|
96
|
+
## Remote endpoints
|
|
97
|
+
|
|
98
|
+
I do not have a remote server, so I was unable to personally test how pi-mtplx interacts with remote endpoints.
|
|
99
|
+
|
|
100
|
+
## Reporting bugs
|
|
78
101
|
|
|
79
|
-
|
|
102
|
+
If you encounter a bug, please open a [GitHub Issue](../../issues) and let me know.
|
|
80
103
|
|
|
81
104
|
## Troubleshooting
|
|
82
105
|
|
|
@@ -86,6 +109,7 @@ Auto shutdown is on by default. When enabled, pi-mtplx stops the managed MTPLX s
|
|
|
86
109
|
| **"No MTPLX models registered"** | Run `/mtplx` → **Models** to discover and register one |
|
|
87
110
|
| **"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
111
|
| **"MTPLX rejected the API key"** | The running server requires a different key. Use `/mtplx` → **API Key** to enter its current key, then retry. |
|
|
112
|
+
| **"Connection error"** with standalone MTPLX | Pi's configured endpoint has no listener. Set `/mtplx` → **Endpoint** to the standalone server's exact URL (including `/v1`), then restart Pi. |
|
|
89
113
|
| **MTPLX startup timed out after 180s** | Run `mtplx status --deep` for MTPLX-side diagnostics (model validation, memory, thermal) |
|
|
90
114
|
|
|
91
115
|
## License
|
package/extensions/mtplx.ts
CHANGED
|
@@ -6,41 +6,69 @@
|
|
|
6
6
|
* and shared helpers (src/utils.ts) into Pi via before_agent_start,
|
|
7
7
|
* agent_end and session_shutdown, plus the /mtplx command.
|
|
8
8
|
*/
|
|
9
|
-
import { acquire, release, stopServer } from "../src/mtplx-process.ts";
|
|
9
|
+
import { acquire, isOwnedByThisSession, release, stopServer, validateServer } from "../src/mtplx-process.ts";
|
|
10
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, loadAutoShutdown, loadMtplxApiKey, loadSsdSessionCache, maskMtplxApiKey, saveAutoShutdown, saveFanMode, saveMtplxApiKey, saveSsdSessionCache, syncMtplxStoredCredential, type FanMode } from "../src/utils.ts";
|
|
12
|
+
import { FAN_MODES, isMtplxModel, loadAutoShutdown, loadAutoStart, loadMtplxApiKey, loadMtplxEndpoint, loadSsdSessionCache, maskMtplxApiKey, mtplxEndpointFromBaseUrl, saveAutoShutdown, saveAutoStart, saveFanMode, saveMtplxApiKey, saveMtplxEndpoint, saveSsdSessionCache, syncMtplxStoredCredential, type FanMode } from "../src/utils.ts";
|
|
13
13
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
|
|
15
|
+
function syncFanModeFromRunningServer(fanMode: string | undefined): { previous: FanMode; current: FanMode } | undefined {
|
|
16
|
+
if (typeof fanMode !== "string" || !(FAN_MODES as readonly string[]).includes(fanMode)) return;
|
|
17
|
+
const current = fanMode as FanMode;
|
|
18
|
+
const previous = getFanMode();
|
|
19
|
+
if (current === previous) return;
|
|
20
|
+
setFanModeValue(current);
|
|
21
|
+
saveFanMode(current);
|
|
22
|
+
return { previous, current };
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
export default function mtplxAutostart(pi: ExtensionAPI): void {
|
|
26
|
+
let lastServerNotice: string | undefined;
|
|
27
|
+
|
|
16
28
|
// Pi's runtime may already have loaded auth.json for this process, but
|
|
17
29
|
// syncing here ensures its next launch uses the same key as models.json.
|
|
18
30
|
syncMtplxStoredCredential();
|
|
19
31
|
|
|
20
32
|
pi.registerCommand("mtplx", {
|
|
21
|
-
description: "MTPLX —
|
|
33
|
+
description: "MTPLX — view server status, configure its endpoint and lifecycle, manage models, or uninstall",
|
|
22
34
|
handler: async (_args, ctx) => {
|
|
23
35
|
const current = await health();
|
|
24
36
|
const status = current ? "on" : "off";
|
|
37
|
+
const fanSync = current ? syncFanModeFromRunningServer(current.fan_mode) : undefined;
|
|
38
|
+
const server = current
|
|
39
|
+
? `Server (serving: ${current.model})`
|
|
40
|
+
: "Server (unavailable — check endpoint or API key)";
|
|
25
41
|
await ctx.ui.setStatus("mtplx", `MTPLX: ${status}`);
|
|
42
|
+
if (fanSync) {
|
|
43
|
+
ctx.ui.notify(
|
|
44
|
+
`MTPLX was already running with fan curve ${fanSync.current}, while Pi's saved default was ${fanSync.previous}. Pi left the server unchanged and updated its saved default to ${fanSync.current}.`,
|
|
45
|
+
"info",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
26
48
|
const ssdSessionCache = loadSsdSessionCache();
|
|
27
49
|
const autoShutdown = loadAutoShutdown();
|
|
50
|
+
const autoStart = loadAutoStart();
|
|
28
51
|
const apiKeyLabel = maskMtplxApiKey(loadMtplxApiKey());
|
|
52
|
+
const endpoint = loadMtplxEndpoint();
|
|
29
53
|
const topChoices = [
|
|
54
|
+
server,
|
|
30
55
|
`Toggle (${status})`,
|
|
31
56
|
`API Key (current: ${apiKeyLabel})`,
|
|
57
|
+
`Endpoint (current: ${endpoint.baseUrl})`,
|
|
32
58
|
`Fan Curves (current: ${getFanMode()})`,
|
|
33
|
-
`Auto Shutdown (current: ${autoShutdown ? "on" : "off"})`,
|
|
59
|
+
`Auto Shutdown Pi-Owned Server (current: ${autoShutdown ? "on" : "off"})`,
|
|
60
|
+
`Auto Start (current: ${autoStart ? "on" : "off"})`,
|
|
34
61
|
`SSD Session Cache (current: ${ssdSessionCache ? "on" : "off"})`,
|
|
35
62
|
"Models",
|
|
36
63
|
"Uninstall (remove provider)",
|
|
37
64
|
];
|
|
38
65
|
const top = await ctx.ui.select("MTPLX", topChoices, undefined);
|
|
39
66
|
if (!top) return;
|
|
67
|
+
if (top === server) return;
|
|
40
68
|
if (top.startsWith("Toggle")) {
|
|
41
69
|
if (current) {
|
|
42
|
-
await stopServer();
|
|
43
|
-
ctx.ui.notify("MTPLX server stopped", "info");
|
|
70
|
+
const stopped = await stopServer();
|
|
71
|
+
ctx.ui.notify(stopped ? "MTPLX server stopped" : "MTPLX is managed separately; pi-mtplx left it running.", stopped ? "info" : "warning");
|
|
44
72
|
} else if (ctx.model && isMtplxModel(ctx.model)) {
|
|
45
73
|
await acquire(ctx.model.id);
|
|
46
74
|
ctx.ui.notify(`MTPLX server started (${ctx.model.id})`, "info");
|
|
@@ -49,6 +77,40 @@ export default function mtplxAutostart(pi: ExtensionAPI): void {
|
|
|
49
77
|
}
|
|
50
78
|
return;
|
|
51
79
|
}
|
|
80
|
+
if (top.startsWith("Endpoint")) {
|
|
81
|
+
const baseUrl = await ctx.ui.input("MTPLX endpoint", `Current: ${endpoint.baseUrl} — e.g. http://127.0.0.1:8001/v1`);
|
|
82
|
+
if (baseUrl === undefined) return;
|
|
83
|
+
try {
|
|
84
|
+
const url = new URL(baseUrl.trim());
|
|
85
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("unsupported protocol");
|
|
86
|
+
} catch {
|
|
87
|
+
ctx.ui.notify("MTPLX endpoint was not updated. Enter an http(s) URL.", "error");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (mtplxEndpointFromBaseUrl(baseUrl).baseUrl === endpoint.baseUrl) {
|
|
91
|
+
ctx.ui.notify("MTPLX endpoint is unchanged.", "info");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (isOwnedByThisSession()) {
|
|
95
|
+
try {
|
|
96
|
+
await stopServer();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
ctx.ui.notify(
|
|
99
|
+
`MTPLX endpoint was not changed because Pi could not stop its running server: ${error instanceof Error ? error.message : String(error)}`,
|
|
100
|
+
"error",
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
ctx.ui.notify("Stopped Pi-owned MTPLX server before changing the endpoint.", "info");
|
|
105
|
+
}
|
|
106
|
+
if (!saveMtplxEndpoint(baseUrl)) {
|
|
107
|
+
ctx.ui.notify("MTPLX endpoint was not updated. Enter an http(s) URL.", "error");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const probe = await healthProbe();
|
|
111
|
+
ctx.ui.notify(probe.health ? "MTPLX endpoint saved and verified. Restart Pi before inference uses it." : "MTPLX endpoint saved, but health verification failed. Check its host, port, and API key; then restart Pi.", probe.health ? "info" : "warning");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
52
114
|
if (top.startsWith("API Key")) {
|
|
53
115
|
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
116
|
if (apiKey === undefined) return;
|
|
@@ -74,21 +136,35 @@ export default function mtplxAutostart(pi: ExtensionAPI): void {
|
|
|
74
136
|
if (!(FAN_MODES as readonly string[]).includes(mode)) return;
|
|
75
137
|
setFanModeValue(mode);
|
|
76
138
|
saveFanMode(mode);
|
|
77
|
-
if (current)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
139
|
+
if (current) await setFanMode();
|
|
140
|
+
ctx.ui.notify(
|
|
141
|
+
current
|
|
142
|
+
? `MTPLX fan curve set to ${getFanMode()} and saved as Pi's default for future Pi-started servers.`
|
|
143
|
+
: `MTPLX fan default saved as ${getFanMode()}. It applies the next time Pi starts MTPLX.`,
|
|
144
|
+
"info",
|
|
145
|
+
);
|
|
81
146
|
}
|
|
82
147
|
if (top.startsWith("Auto Shutdown")) {
|
|
83
148
|
const choices = [
|
|
84
149
|
autoShutdown ? "Off" : "Off (current)",
|
|
85
150
|
autoShutdown ? "On (current)" : "On",
|
|
86
151
|
];
|
|
87
|
-
const picked = await ctx.ui.select("MTPLX — stop the server when Pi exits", choices, undefined);
|
|
152
|
+
const picked = await ctx.ui.select("MTPLX — stop the Pi-owned server when Pi exits", choices, undefined);
|
|
88
153
|
if (!picked) return;
|
|
89
154
|
const enabled = picked.startsWith("On");
|
|
90
155
|
saveAutoShutdown(enabled);
|
|
91
|
-
ctx.ui.notify(`
|
|
156
|
+
ctx.ui.notify(`Pi will ${enabled ? "stop its own MTPLX server" : "leave its own MTPLX server running"} when Pi exits.`, "info");
|
|
157
|
+
}
|
|
158
|
+
if (top.startsWith("Auto Start")) {
|
|
159
|
+
const choices = [
|
|
160
|
+
autoStart ? "Off" : "Off (current)",
|
|
161
|
+
autoStart ? "On (current)" : "On",
|
|
162
|
+
];
|
|
163
|
+
const picked = await ctx.ui.select("MTPLX — autostart MTPLX server when loading an MTPLX model", choices, undefined);
|
|
164
|
+
if (!picked) return;
|
|
165
|
+
const enabled = picked.startsWith("On");
|
|
166
|
+
saveAutoStart(enabled);
|
|
167
|
+
ctx.ui.notify(`MTPLX will ${enabled ? "autostart" : "not autostart"} when loading an MTPLX model.`, "info");
|
|
92
168
|
}
|
|
93
169
|
if (top.startsWith("SSD Session Cache")) {
|
|
94
170
|
const choices = [
|
|
@@ -120,13 +196,36 @@ export default function mtplxAutostart(pi: ExtensionAPI): void {
|
|
|
120
196
|
},
|
|
121
197
|
});
|
|
122
198
|
|
|
123
|
-
|
|
199
|
+
// This must run in `input`, not `before_agent_start`: Pi reports errors
|
|
200
|
+
// from before_agent_start but still sends the prompt to the provider.
|
|
201
|
+
pi.on("input", async (_event, ctx) => {
|
|
124
202
|
if (!ctx.model || !isMtplxModel(ctx.model)) return;
|
|
203
|
+
const autoStart = loadAutoStart();
|
|
125
204
|
try {
|
|
126
|
-
|
|
205
|
+
const server = autoStart
|
|
206
|
+
? await acquire(ctx.model.id)
|
|
207
|
+
: await validateServer(ctx.model.id);
|
|
208
|
+
const fanSync = server.alreadyRunning ? syncFanModeFromRunningServer(server.fanMode) : undefined;
|
|
209
|
+
ctx.ui.setStatus("mtplx", `MTPLX: ${server.model}`);
|
|
210
|
+
if (server.alreadyRunning) {
|
|
211
|
+
const message = autoStart
|
|
212
|
+
? `MTPLX is already running at ${server.endpoint.baseUrl}, serving ${JSON.stringify(server.model)}. Auto Start will not start MTPLX. The model selected in Pi matches; proceeding.`
|
|
213
|
+
: `MTPLX is available at ${server.endpoint.baseUrl}, serving ${JSON.stringify(server.model)}. It matches Pi's selected model; Auto Start is off, so Pi will proceed without managing the server.`;
|
|
214
|
+
const fanMessage = fanSync
|
|
215
|
+
? ` Its fan curve is ${fanSync.current}, while Pi's saved default was ${fanSync.previous}; Pi left the server unchanged and updated its saved default to ${fanSync.current}.`
|
|
216
|
+
: "";
|
|
217
|
+
const notice = message + fanMessage;
|
|
218
|
+
if (notice !== lastServerNotice) ctx.ui.notify(notice, "info");
|
|
219
|
+
lastServerNotice = notice;
|
|
220
|
+
} else {
|
|
221
|
+
lastServerNotice = undefined;
|
|
222
|
+
}
|
|
127
223
|
} catch (error) {
|
|
128
|
-
|
|
224
|
+
lastServerNotice = undefined;
|
|
225
|
+
ctx.ui.notify(`MTPLX request blocked: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
226
|
+
return { action: "handled" };
|
|
129
227
|
}
|
|
228
|
+
return { action: "continue" };
|
|
130
229
|
});
|
|
131
230
|
|
|
132
231
|
pi.on("agent_end", async (_event, ctx) => {
|
package/package.json
CHANGED
package/src/model-discovery.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { join } from "node:path";
|
|
|
20
20
|
import { execFile } from "node:child_process";
|
|
21
21
|
import { promisify } from "node:util";
|
|
22
22
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
-
import { displayNameFromId } from "./utils.ts";
|
|
23
|
+
import { commandError, displayNameFromId } from "./utils.ts";
|
|
24
24
|
|
|
25
25
|
const execFileAsync = promisify(execFile);
|
|
26
26
|
|
|
@@ -114,7 +114,11 @@ export async function listMtplxModels(): Promise<MtplxListedModel[]> {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
export function listedIdentity(model: MtplxListedModel): string {
|
|
117
|
-
|
|
117
|
+
const repo = typeof model.repo_id === "string" ? model.repo_id : "";
|
|
118
|
+
// Forge artifacts have a bare directory name in repo_id. Quickstart treats
|
|
119
|
+
// that as a path relative to Pi's cwd, so use the installed path instead.
|
|
120
|
+
if (repo.includes("/")) return repo;
|
|
121
|
+
return typeof model.path === "string" && model.path ? model.path : repo;
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
/**
|
|
@@ -125,10 +129,41 @@ export function listedIdentity(model: MtplxListedModel): string {
|
|
|
125
129
|
export async function servedModelId(ref: string): Promise<string> {
|
|
126
130
|
const cached = servedIdCache.get(ref);
|
|
127
131
|
if (cached) return cached;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
try {
|
|
133
|
+
const { stdout } = await execFileAsync("mtplx", ["quickstart", "--model", ref, "--dry-run", "--json"], { timeout: 30_000 });
|
|
134
|
+
const id = servedModelIdFromDryRun(JSON.parse(stdout) as unknown);
|
|
135
|
+
servedIdCache.set(ref, id);
|
|
136
|
+
return id;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// MTPLX reports CLI errors as JSON on stdout, which execFile's default
|
|
139
|
+
// error.message omits. Keep that diagnostic visible in the Models menu.
|
|
140
|
+
const stdout = (error as { stdout?: string } | null)?.stdout;
|
|
141
|
+
let detail: unknown;
|
|
142
|
+
try {
|
|
143
|
+
const payload = JSON.parse(stdout ?? "") as { detail?: unknown; error?: unknown };
|
|
144
|
+
detail = payload.detail || payload.error;
|
|
145
|
+
} catch { /* Fall back to stderr / process error below. */ }
|
|
146
|
+
throw new Error(typeof detail === "string" ? detail : commandError(error), { cause: error });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A broken or unfinished artifact must not block management of other models. */
|
|
151
|
+
export async function resolveInstalledModels(
|
|
152
|
+
installed: readonly MtplxListedModel[],
|
|
153
|
+
resolveId: (ref: string) => Promise<string> = servedModelId,
|
|
154
|
+
): Promise<{ resolved: ResolvedMtplxModel[]; failures: string[] }> {
|
|
155
|
+
const resolved: ResolvedMtplxModel[] = [];
|
|
156
|
+
const failures: string[] = [];
|
|
157
|
+
for (const model of installed) {
|
|
158
|
+
const ref = listedIdentity(model);
|
|
159
|
+
if (!ref) continue;
|
|
160
|
+
try {
|
|
161
|
+
resolved.push({ model, ref, id: await resolveId(ref) });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
failures.push(`${ref}: ${error instanceof Error ? error.message : String(error)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { resolved, failures };
|
|
132
167
|
}
|
|
133
168
|
|
|
134
169
|
/** Parse the model id from MTPLX's machine-readable quickstart plan. */
|
|
@@ -281,17 +316,11 @@ export async function manageModels(ctx: ExtensionContext): Promise<boolean> {
|
|
|
281
316
|
ctx.ui.notify("No MTPLX models found. Install one with `mtplx install`.", "warning");
|
|
282
317
|
return false;
|
|
283
318
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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;
|
|
319
|
+
const { resolved, failures } = await resolveInstalledModels(installed);
|
|
320
|
+
if (failures.length > 0) {
|
|
321
|
+
ctx.ui.notify(`Skipped ${failures.length} MTPLX model(s) whose served ID could not be read:\n${failures.join("\n")}`, "warning");
|
|
294
322
|
}
|
|
323
|
+
if (resolved.length === 0) return false;
|
|
295
324
|
const migrated = migrateRegisteredModelIds(resolved);
|
|
296
325
|
if (migrated.length > 0) {
|
|
297
326
|
ctx.ui.notify(`Updated registered model IDs to MTPLX's canonical names. Restart Pi before selecting them.`, "info");
|
package/src/mtplx-client.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Minimal HTTP client for the
|
|
3
|
-
*
|
|
2
|
+
* Minimal HTTP client for the configured MTPLX OpenAI-compatible endpoint.
|
|
3
|
+
* Only touches endpoints that MTPLX exposes:
|
|
4
4
|
* GET /health
|
|
5
5
|
* POST /v1/mtplx/thermal/fan_mode
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { type FanMode, type MtplxEndpoint, loadFanMode, loadMtplxEndpoint, loadResolvedMtplxApiKey } from "./utils.ts";
|
|
8
8
|
|
|
9
9
|
export type Health = { ok: true; model: string; model_path?: string; fan_mode?: string };
|
|
10
10
|
export type HealthProbe = { health: Health | undefined; authenticationRejected?: true };
|
|
@@ -33,12 +33,12 @@ export function authenticationFailureMessage(): string {
|
|
|
33
33
|
return "MTPLX rejected Pi's API key. Update it via /mtplx → API Key.";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export async function healthProbe(): Promise<HealthProbe> {
|
|
36
|
+
export async function healthProbe(endpoint = loadMtplxEndpoint()): Promise<HealthProbe> {
|
|
37
37
|
const controller = new AbortController();
|
|
38
38
|
const timeout = setTimeout(() => controller.abort(), 1_500);
|
|
39
39
|
try {
|
|
40
40
|
const auth = authRequest();
|
|
41
|
-
const response = await fetch(
|
|
41
|
+
const response = await fetch(`${endpoint.origin}/health`, {
|
|
42
42
|
headers: auth.headers,
|
|
43
43
|
signal: controller.signal,
|
|
44
44
|
});
|
|
@@ -63,8 +63,8 @@ export async function healthProbe(): Promise<HealthProbe> {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
export async function health(): Promise<Health | undefined> {
|
|
67
|
-
return (await healthProbe()).health;
|
|
66
|
+
export async function health(endpoint?: MtplxEndpoint): Promise<Health | undefined> {
|
|
67
|
+
return (await healthProbe(endpoint)).health;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export async function setFanMode(): Promise<void> {
|
|
@@ -72,7 +72,7 @@ export async function setFanMode(): Promise<void> {
|
|
|
72
72
|
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
73
73
|
try {
|
|
74
74
|
const auth = authRequest();
|
|
75
|
-
const response = await fetch(
|
|
75
|
+
const response = await fetch(`${loadMtplxEndpoint().origin}/v1/mtplx/thermal/fan_mode`, {
|
|
76
76
|
method: "POST",
|
|
77
77
|
headers: { "content-type": "application/json", ...auth.headers },
|
|
78
78
|
body: JSON.stringify({ mode: fanMode }),
|
package/src/mtplx-process.ts
CHANGED
|
@@ -7,22 +7,18 @@
|
|
|
7
7
|
* Pi's model id — a positive fingerprint that a given healthy server on
|
|
8
8
|
* the port is one this extension owns.
|
|
9
9
|
* - Cleanup is therefore precise: a server only gets shut down if the
|
|
10
|
-
* extension spawned it in this session
|
|
11
|
-
*
|
|
12
|
-
* ids. A foreign service on the port is never touched, and a manually
|
|
13
|
-
* started MTPLX server (no matching --model-id fingerprint) is never
|
|
14
|
-
* killed — it is simply served from as-is.
|
|
10
|
+
* extension spawned it in this Pi session. A manually or separately
|
|
11
|
+
* started server is never killed, even when it serves a registered model.
|
|
15
12
|
* - A server launched by this Pi session is stopped through its own detached
|
|
16
13
|
* process group. This avoids relying on a second `mtplx` CLI invocation
|
|
17
|
-
* during shutdown.
|
|
18
|
-
* session still uses MTPLX's host-and-port stop command.
|
|
14
|
+
* during shutdown.
|
|
19
15
|
*/
|
|
20
16
|
import { spawn, execFile } from "node:child_process";
|
|
21
17
|
import { promisify } from "node:util";
|
|
22
18
|
import type { ChildProcess } from "node:child_process";
|
|
23
19
|
import { authenticationFailureMessage, getFanMode, health, healthProbe, setFanMode } from "./mtplx-client.ts";
|
|
24
20
|
import { MTPLX_MODELS } from "./model-discovery.ts";
|
|
25
|
-
import {
|
|
21
|
+
import { READY_TIMEOUT_MS, POLL_MS, loadMtplxEndpoint, loadResolvedMtplxApiKey, loadSsdSessionCache, sleep, commandError, portIsOccupied, type MtplxEndpoint } from "./utils.ts";
|
|
26
22
|
|
|
27
23
|
const execFileAsync = promisify(execFile);
|
|
28
24
|
|
|
@@ -43,34 +39,30 @@ export function startupError(cause: Error): Error {
|
|
|
43
39
|
}
|
|
44
40
|
|
|
45
41
|
/**
|
|
46
|
-
* Shut down the server
|
|
47
|
-
*
|
|
48
|
-
* a kill target.
|
|
42
|
+
* Shut down the server at the configured endpoint, but only when this Pi
|
|
43
|
+
* session launched it. A healthy external listener is never a kill target.
|
|
49
44
|
*/
|
|
50
|
-
export async function stopServer(): Promise<
|
|
45
|
+
export async function stopServer(): Promise<boolean> {
|
|
46
|
+
const endpoint = loadMtplxEndpoint();
|
|
51
47
|
const probe = await healthProbe();
|
|
52
48
|
const current = probe.health;
|
|
53
49
|
if (!current) {
|
|
54
|
-
if (await portIsOccupied()) {
|
|
50
|
+
if (await portIsOccupied(endpoint)) {
|
|
55
51
|
if (probe.authenticationRejected) throw new Error(authenticationFailureMessage());
|
|
56
|
-
throw new Error(`MTPLX cannot use ${
|
|
52
|
+
throw new Error(`MTPLX cannot use ${endpoint.host}:${endpoint.port}: another, non-MTPLX service is listening there.`);
|
|
57
53
|
}
|
|
58
54
|
// Nothing listening (or not MTPLX): nothing to stop. Drop any stale handle.
|
|
59
55
|
ownedChild = undefined;
|
|
60
|
-
return;
|
|
56
|
+
return true;
|
|
61
57
|
}
|
|
62
58
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// the user). MTPLX exposes no reliable way to distinguish it from a
|
|
67
|
-
// Pi-managed one at the port level beyond --model-id, so do not kill
|
|
68
|
-
// it. If a Pi-owned one is still needed, the user can run /mtplx →
|
|
69
|
-
// Toggle.
|
|
59
|
+
if (!isOwnedByThisSession()) {
|
|
60
|
+
// A separately started server can deliberately use the same model id as a
|
|
61
|
+
// Pi-managed one, so model identity is not ownership.
|
|
70
62
|
console.warn(
|
|
71
|
-
`pi-mtplx: leaving MTPLX server on ${
|
|
63
|
+
`pi-mtplx: leaving MTPLX server on ${endpoint.host}:${endpoint.port} (model ${JSON.stringify(current.model)}) untouched — not owned by this Pi session.`,
|
|
72
64
|
);
|
|
73
|
-
return;
|
|
65
|
+
return false;
|
|
74
66
|
}
|
|
75
67
|
|
|
76
68
|
let stopError: unknown;
|
|
@@ -91,13 +83,10 @@ export async function stopServer(): Promise<void> {
|
|
|
91
83
|
}
|
|
92
84
|
}
|
|
93
85
|
|
|
94
|
-
if (!isOwnedByThisSession() && await signalMtplxListener()) {
|
|
95
|
-
signalledOwnedProcess = true;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
86
|
if (!signalledOwnedProcess) {
|
|
99
87
|
try {
|
|
100
|
-
|
|
88
|
+
if (!endpoint.isLoopback) throw new Error("MTPLX endpoint is remote; pi-mtplx will not stop a server it did not launch.");
|
|
89
|
+
await execFileAsync("mtplx", ["stop", "--host", endpoint.host, "--port", String(endpoint.port), "--json"], { timeout: 20_000 });
|
|
101
90
|
stopError = undefined;
|
|
102
91
|
} catch (error) {
|
|
103
92
|
// Some MTPLX CLI failures occur after it has already signalled the server.
|
|
@@ -108,58 +97,47 @@ export async function stopServer(): Promise<void> {
|
|
|
108
97
|
|
|
109
98
|
const deadline = Date.now() + 20_000;
|
|
110
99
|
while (Date.now() < deadline) {
|
|
111
|
-
if (!(await portIsOccupied())) {
|
|
100
|
+
if (!(await portIsOccupied(endpoint))) {
|
|
112
101
|
ownedChild = undefined;
|
|
113
|
-
return;
|
|
102
|
+
return true;
|
|
114
103
|
}
|
|
115
104
|
await sleep(POLL_MS);
|
|
116
105
|
}
|
|
117
106
|
if (stopError) throw new Error(`MTPLX shutdown failed: ${commandError(stopError)}`);
|
|
118
107
|
if (signalledOwnedProcess) {
|
|
119
|
-
throw new Error(`MTPLX shutdown timed out after signalling Pi's server process; ${
|
|
108
|
+
throw new Error(`MTPLX shutdown timed out after signalling Pi's server process; ${endpoint.host}:${endpoint.port} is still occupied.`);
|
|
120
109
|
}
|
|
121
|
-
throw new Error(`MTPLX shutdown timed out; ${
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Positive ownership fingerprint: /health model id belongs to this extension's registry. */
|
|
125
|
-
function currentModelIsOurs(model: string): boolean {
|
|
126
|
-
return Object.keys(MTPLX_MODELS).includes(model);
|
|
110
|
+
throw new Error(`MTPLX shutdown timed out; ${endpoint.host}:${endpoint.port} is still occupied.`);
|
|
127
111
|
}
|
|
128
112
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
}
|
|
113
|
+
function modelMismatchError(runningModel: string, requestedModel: string, endpoint: MtplxEndpoint): Error {
|
|
114
|
+
return new Error(
|
|
115
|
+
`MTPLX is already running at ${endpoint.baseUrl}, serving ${JSON.stringify(runningModel)}, but Pi has ${JSON.stringify(requestedModel)} selected. ` +
|
|
116
|
+
`Pi will not replace the running server. Select ${JSON.stringify(runningModel)} in Pi, or restart MTPLX with \`--model-id ${requestedModel}\`, then try again.`,
|
|
117
|
+
);
|
|
148
118
|
}
|
|
149
119
|
|
|
150
|
-
function
|
|
120
|
+
function autoStartDisabledUnavailableError(endpoint: MtplxEndpoint): Error {
|
|
151
121
|
return new Error(
|
|
152
|
-
`MTPLX is
|
|
153
|
-
|
|
122
|
+
`MTPLX is unavailable at ${endpoint.baseUrl}. Auto Start is off, so Pi will not start a server. ` +
|
|
123
|
+
"Start MTPLX at this endpoint or enable Auto Start, then try again.",
|
|
154
124
|
);
|
|
155
125
|
}
|
|
156
126
|
|
|
127
|
+
export type ReadyServer = {
|
|
128
|
+
endpoint: MtplxEndpoint;
|
|
129
|
+
model: string;
|
|
130
|
+
fanMode?: string;
|
|
131
|
+
alreadyRunning: boolean;
|
|
132
|
+
};
|
|
133
|
+
|
|
157
134
|
/**
|
|
158
135
|
* Spawn the MTPLX server for a registered model and wait until /health
|
|
159
136
|
* confirms it serves exactly that model. The child is spawned detached so it
|
|
160
137
|
* outlives Pi's event-loop teardown, and unref'd so it never blocks Pi exit.
|
|
161
138
|
*/
|
|
162
139
|
export async function startServer(modelId: string): Promise<void> {
|
|
140
|
+
const endpoint = loadMtplxEndpoint();
|
|
163
141
|
const configured = MTPLX_MODELS[modelId];
|
|
164
142
|
if (!configured) {
|
|
165
143
|
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.`);
|
|
@@ -174,9 +152,9 @@ export async function startServer(modelId: string): Promise<void> {
|
|
|
174
152
|
"--fan-mode",
|
|
175
153
|
getFanMode(),
|
|
176
154
|
"--host",
|
|
177
|
-
|
|
155
|
+
endpoint.host,
|
|
178
156
|
"--port",
|
|
179
|
-
String(
|
|
157
|
+
String(endpoint.port),
|
|
180
158
|
// Explicitly pass the user's persisted choice; this extension defaults it to on.
|
|
181
159
|
"--ssd-session-cache",
|
|
182
160
|
loadSsdSessionCache() ? "on" : "off",
|
|
@@ -213,37 +191,58 @@ export async function startServer(modelId: string): Promise<void> {
|
|
|
213
191
|
}
|
|
214
192
|
|
|
215
193
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
194
|
+
* Confirm that the configured endpoint is healthy and serves `modelId`.
|
|
195
|
+
* Unlike `ensureServer`, this never starts, stops, or switches a server.
|
|
218
196
|
*/
|
|
219
|
-
export async function
|
|
220
|
-
const probe = await healthProbe();
|
|
197
|
+
export async function validateServer(modelId: string, endpoint = loadMtplxEndpoint()): Promise<ReadyServer> {
|
|
198
|
+
const probe = await healthProbe(endpoint);
|
|
221
199
|
const current = probe.health;
|
|
222
200
|
if (current?.model === modelId) {
|
|
223
|
-
|
|
224
|
-
|
|
201
|
+
return { endpoint, model: current.model, fanMode: current.fan_mode, alreadyRunning: true };
|
|
202
|
+
}
|
|
203
|
+
if (current) {
|
|
204
|
+
throw modelMismatchError(current.model, modelId, endpoint);
|
|
225
205
|
}
|
|
206
|
+
if (probe.authenticationRejected) throw new Error(authenticationFailureMessage());
|
|
207
|
+
throw autoStartDisabledUnavailableError(endpoint);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Ensure the configured endpoint serves `modelId`. A healthy separately
|
|
212
|
+
* managed server is authoritative: Pi reuses a matching one and never
|
|
213
|
+
* replaces a different model. A server started by this Pi session can be
|
|
214
|
+
* stopped and switched to the selected model. Pi starts a server only when
|
|
215
|
+
* the endpoint is unavailable or after switching its own server.
|
|
216
|
+
*/
|
|
217
|
+
export async function ensureServer(modelId: string, endpoint = loadMtplxEndpoint()): Promise<ReadyServer> {
|
|
218
|
+
const probe = await healthProbe(endpoint);
|
|
219
|
+
const current = probe.health;
|
|
226
220
|
if (current) {
|
|
227
|
-
if (
|
|
228
|
-
|
|
221
|
+
if (current.model === modelId) {
|
|
222
|
+
return { endpoint, model: current.model, fanMode: current.fan_mode, alreadyRunning: true };
|
|
229
223
|
}
|
|
224
|
+
if (!isOwnedByThisSession()) throw modelMismatchError(current.model, modelId, endpoint);
|
|
230
225
|
await stopServer();
|
|
231
226
|
}
|
|
232
|
-
|
|
227
|
+
if (await portIsOccupied(endpoint)) {
|
|
233
228
|
if (probe.authenticationRejected) throw new Error(authenticationFailureMessage());
|
|
234
|
-
throw new Error(`MTPLX cannot start because ${
|
|
229
|
+
throw new Error(`MTPLX cannot start because ${endpoint.host}:${endpoint.port} is occupied by a non-MTPLX service.`);
|
|
230
|
+
}
|
|
231
|
+
if (!endpoint.isLoopback) {
|
|
232
|
+
throw new Error(`MTPLX endpoint ${endpoint.baseUrl} is unavailable. pi-mtplx only auto-starts loopback servers; start this endpoint separately.`);
|
|
235
233
|
}
|
|
236
234
|
await startServer(modelId);
|
|
237
235
|
await setFanMode();
|
|
236
|
+
return { endpoint, model: modelId, fanMode: getFanMode(), alreadyRunning: false };
|
|
238
237
|
}
|
|
239
238
|
|
|
240
|
-
let transition: Promise<
|
|
239
|
+
let transition: Promise<ReadyServer> | undefined;
|
|
241
240
|
let transitionModel: string | undefined;
|
|
242
241
|
let activeModel: string | undefined;
|
|
243
242
|
let activeAgents = 0;
|
|
244
243
|
let idleWaiters: Array<() => void> = [];
|
|
245
244
|
|
|
246
|
-
export function ensureOnce(modelId: string): Promise<
|
|
245
|
+
export function ensureOnce(modelId: string): Promise<ReadyServer> {
|
|
247
246
|
if (transition) {
|
|
248
247
|
if (transitionModel === modelId) return transition;
|
|
249
248
|
return transition.then(() => ensureOnce(modelId));
|
|
@@ -256,7 +255,7 @@ export function ensureOnce(modelId: string): Promise<void> {
|
|
|
256
255
|
return transition;
|
|
257
256
|
}
|
|
258
257
|
|
|
259
|
-
export async function acquire(modelId: string): Promise<
|
|
258
|
+
export async function acquire(modelId: string): Promise<ReadyServer> {
|
|
260
259
|
// Never replace a model while an already-admitted MTPLX agent is running.
|
|
261
260
|
// Same-model requests share both this lease and any in-flight start Promise.
|
|
262
261
|
while (activeAgents > 0 && activeModel !== modelId) {
|
|
@@ -268,7 +267,7 @@ export async function acquire(modelId: string): Promise<void> {
|
|
|
268
267
|
activeModel = modelId;
|
|
269
268
|
activeAgents += 1;
|
|
270
269
|
try {
|
|
271
|
-
await ensureOnce(modelId);
|
|
270
|
+
return await ensureOnce(modelId);
|
|
272
271
|
} catch (error) {
|
|
273
272
|
release();
|
|
274
273
|
throw error;
|
package/src/utils.ts
CHANGED
|
@@ -17,8 +17,49 @@ export type FanMode = "default" | "smart" | "max";
|
|
|
17
17
|
export const FAN_MODES: readonly FanMode[] = ["default", "smart", "max"];
|
|
18
18
|
export const DEFAULT_SSD_SESSION_CACHE = true;
|
|
19
19
|
export const DEFAULT_AUTO_SHUTDOWN = true;
|
|
20
|
+
export const DEFAULT_AUTO_START = true;
|
|
20
21
|
export const DEFAULT_MTPLX_API_KEY = "mtplx-local";
|
|
21
22
|
|
|
23
|
+
export type MtplxEndpoint = {
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
origin: string;
|
|
26
|
+
host: string;
|
|
27
|
+
port: number;
|
|
28
|
+
isLoopback: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Resolve the endpoint from Pi's provider configuration, with a local default. */
|
|
32
|
+
export function mtplxEndpointFromBaseUrl(baseUrl: unknown): MtplxEndpoint {
|
|
33
|
+
const fallback = `http://${HOST}:${PORT}/v1`;
|
|
34
|
+
let url: URL;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(typeof baseUrl === "string" && baseUrl.trim() ? baseUrl.trim() : fallback);
|
|
37
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("unsupported protocol");
|
|
38
|
+
} catch {
|
|
39
|
+
url = new URL(fallback);
|
|
40
|
+
}
|
|
41
|
+
// URL.hostname retains brackets around IPv6 literals ("[::1]"). The
|
|
42
|
+
// MTPLX CLI and node:net both expect the bare address, and ::1 is local.
|
|
43
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
44
|
+
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
45
|
+
return {
|
|
46
|
+
baseUrl: url.toString().replace(/\/$/, ""),
|
|
47
|
+
origin: url.origin,
|
|
48
|
+
host,
|
|
49
|
+
port,
|
|
50
|
+
isLoopback: host === "127.0.0.1" || host === "::1" || host.toLowerCase() === "localhost",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadMtplxEndpoint(): MtplxEndpoint {
|
|
55
|
+
try {
|
|
56
|
+
const catalog = JSON.parse(readFileSync(PI_MODELS_FILE, "utf8")) as { providers?: { mtplx?: { baseUrl?: unknown } } };
|
|
57
|
+
return mtplxEndpointFromBaseUrl(catalog.providers?.mtplx?.baseUrl);
|
|
58
|
+
} catch {
|
|
59
|
+
return mtplxEndpointFromBaseUrl(undefined);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
22
63
|
// Fan mode ("fan curve") applied at autostart, live-updated from `/mtplx` while the
|
|
23
64
|
// server runs. Persisted to disk so the choice survives Pi restarts: `fanMode` is a
|
|
24
65
|
// module-level variable that would reset to "smart" on every fresh session, silently
|
|
@@ -74,6 +115,26 @@ export function saveSsdSessionCache(enabled: boolean): void {
|
|
|
74
115
|
// separate from the server's own settings so users can deliberately leave a
|
|
75
116
|
// loaded model available after Pi exits.
|
|
76
117
|
export const AUTO_SHUTDOWN_FILE = join(homedir(), ".pi", "agent", "mtplx-auto-shutdown.json");
|
|
118
|
+
export const AUTO_START_FILE = join(homedir(), ".pi", "agent", "mtplx-auto-start.json");
|
|
119
|
+
|
|
120
|
+
export function loadAutoStart(): boolean {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(readFileSync(AUTO_START_FILE, "utf8")) as { enabled?: unknown };
|
|
123
|
+
if (typeof parsed.enabled === "boolean") return parsed.enabled;
|
|
124
|
+
} catch {
|
|
125
|
+
// missing or corrupt file → fall back to the default
|
|
126
|
+
}
|
|
127
|
+
return DEFAULT_AUTO_START;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function saveAutoStart(enabled: boolean): void {
|
|
131
|
+
try {
|
|
132
|
+
mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
133
|
+
writeFileSync(AUTO_START_FILE, JSON.stringify({ enabled }, null, 2) + "\n");
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error(`MTPLX could not persist auto-start preference: ${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
77
138
|
|
|
78
139
|
export function loadAutoShutdown(): boolean {
|
|
79
140
|
try {
|
|
@@ -203,6 +264,37 @@ export function saveMtplxApiKey(apiKey: string): boolean {
|
|
|
203
264
|
}
|
|
204
265
|
}
|
|
205
266
|
|
|
267
|
+
/** Save the OpenAI-compatible base URL used by Pi and lifecycle health checks. */
|
|
268
|
+
export function saveMtplxEndpoint(baseUrl: string): boolean {
|
|
269
|
+
const normalized = baseUrl.trim();
|
|
270
|
+
if (!normalized) return false;
|
|
271
|
+
let endpoint: MtplxEndpoint;
|
|
272
|
+
try {
|
|
273
|
+
const url = new URL(normalized);
|
|
274
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
275
|
+
endpoint = mtplxEndpointFromBaseUrl(normalized);
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const catalog = existsSync(PI_MODELS_FILE)
|
|
281
|
+
? JSON.parse(readFileSync(PI_MODELS_FILE, "utf8")) as Record<string, unknown>
|
|
282
|
+
: {};
|
|
283
|
+
if (typeof catalog !== "object" || catalog === null || Array.isArray(catalog)) throw new Error("models.json is not an object");
|
|
284
|
+
const providers = (catalog.providers ??= {}) as Record<string, unknown>;
|
|
285
|
+
const provider = (providers.mtplx ?? { api: "openai-completions", authHeader: true }) as Record<string, unknown>;
|
|
286
|
+
if (typeof provider !== "object" || provider === null || Array.isArray(provider)) throw new Error("models.json mtplx provider is not an object");
|
|
287
|
+
provider.baseUrl = endpoint.baseUrl;
|
|
288
|
+
providers.mtplx = provider;
|
|
289
|
+
mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
290
|
+
writeFileSync(PI_MODELS_FILE, JSON.stringify(catalog, null, 2) + "\n");
|
|
291
|
+
return true;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error(`MTPLX could not update endpoint: ${error instanceof Error ? error.message : String(error)}`);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
206
298
|
export function displayNameFromId(id: string): string {
|
|
207
299
|
return id
|
|
208
300
|
.replace(/^mtplx-/, "")
|
|
@@ -227,9 +319,9 @@ export function commandError(error: unknown): string {
|
|
|
227
319
|
return stderr || details.message || "unknown command failure";
|
|
228
320
|
}
|
|
229
321
|
|
|
230
|
-
export function portIsOccupied(): Promise<boolean> {
|
|
322
|
+
export function portIsOccupied(endpoint = loadMtplxEndpoint()): Promise<boolean> {
|
|
231
323
|
return new Promise((resolve) => {
|
|
232
|
-
const socket = createConnection({ host:
|
|
324
|
+
const socket = createConnection({ host: endpoint.host, port: endpoint.port });
|
|
233
325
|
const done = (occupied: boolean) => {
|
|
234
326
|
socket.destroy();
|
|
235
327
|
resolve(occupied);
|