pi-mtplx 0.1.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/LICENSE +21 -0
- package/README.md +166 -0
- package/extensions/mtplx-tkps-footer.ts +40 -0
- package/extensions/mtplx.ts +90 -0
- package/package.json +53 -0
- package/src/model-discovery.ts +181 -0
- package/src/mtplx-client.ts +63 -0
- package/src/mtplx-process.ts +213 -0
- package/src/utils.ts +101 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 KrossKinetic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# pi-mtplx
|
|
2
|
+
|
|
3
|
+
Zero-config [MTPLX](https://github.com/youssofal/MTPLX) integration for the [Pi](https://pi.dev) coding agent. Install it, switch to a `mtplx` model, and pi-mtplx handles the rest: discovering installed MTPLX models, booting a local MTPLX OpenAI-compatible server on demand, switching models transparently, live fan-curve control, and clean shutdown when Pi exits — without ever touching MTPLX processes that Pi doesn't own.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pi install npm:pi-mtplx
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Or from GitHub:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pi install git:github.com/KrossKinetic/pi-mtplx
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Zero-config model discovery** — `/mtplx → Models` lists every model in your MTPLX cache and registers the one you pick into Pi's catalog (`~/.pi/agent/models.json`), no manual provider setup.
|
|
18
|
+
- **On-demand autostart** — the first time you run an agent with a `mtplx` model, the local MTPLX server boots itself and waits until `/health` confirms it's serving exactly that model.
|
|
19
|
+
- **Transparent model switching** — switch from model A to model B with `/model`; pi-mtplx stops A and starts B before the next request proceeds.
|
|
20
|
+
- **Process ownership** — pi-mtplx only ever stops servers it can positively identify as its own (see [Automatic MTPLX Lifecycle](#automatic-mtplx-lifecycle)). Manually started MTPLX servers are left alone.
|
|
21
|
+
- **Fan-curve controls** — pick `default`, `smart`, or `max` fan modes; applied at boot and live-updated over the MTPLX thermal endpoint while the server runs.
|
|
22
|
+
- **Clean shutdown** — `/quit` stops the MTPLX server this Pi session owns; no orphaned processes.
|
|
23
|
+
- **tk/s footer** — a companion extension reports the token generation rate of the last assistant turn (`⚡NN.N tk/s`) in the footer.
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- macOS on Apple Silicon (MTPLX requirement)
|
|
28
|
+
- The `mtplx` CLI on your `PATH` (pi-mtplx shells out to `mtplx quickstart/stop/list` and reads `GET /health` / `POST /v1/mtplx/thermal/fan_mode`)
|
|
29
|
+
- Node.js ≥ 20 (Pi requirement)
|
|
30
|
+
- At least one MTPLX model installed (`mtplx install <repo>` or `mtplx models --update`)
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pi install npm:pi-mtplx
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
or:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
pi install git:github.com/KrossKinetic/pi-mtplx
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
After installing, make sure `mtplx` is on the PATH that Pi runs under, then restart Pi (or `/reload`).
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
1. Ensure a model is installed: `mtplx models` (or `mtplx install <repo-id>`).
|
|
49
|
+
2. In Pi, run `/mtplx` → **Models (register)** and pick your model. This maps your Pi model id to the MTPLX artifact and adds the `mtplx` provider to `~/.pi/agent/models.json`.
|
|
50
|
+
3. `/reload` (or restart Pi), then switch to the model with `/model`.
|
|
51
|
+
4. Send a message. On the first request pi-mtplx boots the MTPLX server and waits for it to be ready.
|
|
52
|
+
5. `/mtplx` → **Toggle (off)** to stop it, or just `/quit` — the server shuts down with Pi.
|
|
53
|
+
|
|
54
|
+
## How It Works
|
|
55
|
+
|
|
56
|
+
pi-mtplx runs an OpenAI-compatible MTPLX server at `http://127.0.0.1:8000` and registers a `mtplx` provider in Pi's model catalog pointing at it. Every time an agent turn starts on a `mtplx` model, the extension checks the server's `/health` endpoint; if the right model isn't being served, it fixes that before the request is admitted (see [Automatic MTPLX Lifecycle](#automatic-mtplx-lifecycle)). Model ids are stable: the server is started with `mtplx quickstart --model <ref> --model-id <pi-model-id>`, so `/health` and `/v1/models` report Pi's id and the extension can always tell which model a healthy server is serving.
|
|
57
|
+
|
|
58
|
+
## `/mtplx` Commands
|
|
59
|
+
|
|
60
|
+
`/mtplx` opens a menu:
|
|
61
|
+
|
|
62
|
+
| Choice | What it does |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| **Toggle (on/off)** | Starts or stops the MTPLX server. Stopping only affects servers pi-mtplx owns (see below). Starting with a `mtplx` model active boots that model. |
|
|
65
|
+
| **Fan Curves** | Choose `default`, `smart`, or `max`. Persisted to `~/.pi/agent/mtplx-fanmode.json`, applied at next boot, and pushed live to a running server via `POST /v1/mtplx/thermal/fan_mode`. |
|
|
66
|
+
| **Models (register)** | Lists models from your MTPLX cache (`mtplx list --json`), marks which are already registered in Pi, and registers your pick: saved to `~/.pi/agent/mtplx-models.json` and added to the `mtplx` provider in `~/.pi/agent/models.json`. |
|
|
67
|
+
| **Uninstall** | Removes the `mtplx` provider (and only that provider) from `~/.pi/agent/models.json` after a confirmation. The registry file and installed MTPLX models are untouched. |
|
|
68
|
+
|
|
69
|
+
## Model Discovery
|
|
70
|
+
|
|
71
|
+
`/mtplx → Models` shells out to `mtplx list --json`, which reports every artifact in the local MTPLX model cache (`~/.mtplx/models`). Each entry's `repo_id` (or path) is mapped to a Pi model id with the scheme `mtplx-<slug>`, e.g. `Youssofal/Qwen3.8-27B-MTPLX-Optimized-Quality` → `mtplx-qwen38-27b-optimized-quality`.
|
|
72
|
+
|
|
73
|
+
Two files back this:
|
|
74
|
+
|
|
75
|
+
- `~/.pi/agent/mtplx-models.json` — Pi model id → MTPLX artifact ref. This is pi-mtplx's own registry; new registrations are appended here. (A built-in fallback map ships in the package for `mtplx-qwen38-27b-optimized-quality`.)
|
|
76
|
+
- `~/.pi/agent/models.json` — Pi's model catalog. pi-mtplx only ever adds to or removes its own `mtplx` provider entry there; it never rewrites other providers.
|
|
77
|
+
|
|
78
|
+
Registered models are OpenAI-completions models with a 262144 context window, 65536 max output tokens, zero cost (local), and reasoning enabled.
|
|
79
|
+
|
|
80
|
+
## Automatic MTPLX Lifecycle
|
|
81
|
+
|
|
82
|
+
- **Boot**: on the first `before_agent_start` for a `mtplx` model, if nothing healthy is on port 8000, pi-mtplx runs `mtplx quickstart --model <ref> --model-id <id> --profile sustained --fan-mode <mode> --host 127.0.0.1 --port 8000 --ssd-session-cache off`, detached and unref'd, then polls `/health` (500 ms) until it reports the requested model id, up to 3 minutes.
|
|
83
|
+
- **Switching**: if `/health` reports a *different* model, pi-mtplx stops it first, then starts the requested one. Requests are serialized: a model is never swapped out while an agent already admitted on the current model is still running.
|
|
84
|
+
- **Stopping**: goes through `mtplx stop --host 127.0.0.1 --port 8000 --json` (MTPLX's own graceful stop: SIGTERM → grace period → SIGKILL), then polls `/health` until the port stops answering.
|
|
85
|
+
- **Shutdown**: on `/quit` (`session_shutdown` with reason `quit`), pi-mtplx stops the server it can identify as its own. If nothing is running, this is a no-op.
|
|
86
|
+
|
|
87
|
+
### Process ownership
|
|
88
|
+
|
|
89
|
+
pi-mtplx distinguishes *its* MTPLX process from one you started manually, and cleanup only touches the former:
|
|
90
|
+
|
|
91
|
+
1. **Session handle** — when pi-mtplx spawns the server, it keeps the child handle. A server it spawned in this session is always owned.
|
|
92
|
+
2. **Health fingerprint** — pi-mtplx always starts its server with `--model-id <pi-model-id>`, and only model ids from its own registry qualify. If `/health` reports a model id that is in pi-mtplx's registry, the server is treated as Pi-owned even across Pi restarts (the process handle doesn't survive a restart, the fingerprint does).
|
|
93
|
+
|
|
94
|
+
What this means in practice:
|
|
95
|
+
|
|
96
|
+
- A **manually started** MTPLX server (e.g. `mtplx quickstart` with its own flags, or no `--model-id` matching a pi-mtplx registry id) is **never killed** by pi-mtplx — neither on model switch, `/mtplx → Toggle` stop, nor `/quit`. You'll get a console note telling you to use `/mtplx → Toggle` if you want it stopped. If you rely on a manual server, keep its `--model-id` out of pi-mtplx's registry ids, or just run `/mtplx → Toggle` manually.
|
|
97
|
+
- A **non-MTPLX** service on port 8000 is never touched; pi-mtplx refuses to start and tells you the port is occupied.
|
|
98
|
+
- If MTPLX ever changes what `/health` reports, ownership may degrade to the session handle only — in that case pi-mtplx is conservative and will not stop cross-session servers.
|
|
99
|
+
|
|
100
|
+
## Model Switching
|
|
101
|
+
|
|
102
|
+
Switch with `/model` as usual. Behind the scenes, on the next agent turn:
|
|
103
|
+
|
|
104
|
+
1. `before_agent_start` acquires the model lease.
|
|
105
|
+
2. `/health` is checked. If the running server serves a different `mtplx` model, pi-mtplx stops it (ownership rules above) and starts the new one.
|
|
106
|
+
3. Only once `/health` confirms the requested model is served does the request proceed.
|
|
107
|
+
4. On `agent_end` the lease is released; a different model can then be started for the next turn.
|
|
108
|
+
|
|
109
|
+
Concurrent same-model requests share the in-flight start; different-model requests queue behind running agents instead of swapping the model out from under them.
|
|
110
|
+
|
|
111
|
+
## Configuration
|
|
112
|
+
|
|
113
|
+
There is no config file to write. The extension uses fixed, documented defaults:
|
|
114
|
+
|
|
115
|
+
| Setting | Value |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| Server host:port | `127.0.0.1:8000` (MTPLX default) |
|
|
118
|
+
| Quickstart profile | `sustained` |
|
|
119
|
+
| SSD session cache | `off` |
|
|
120
|
+
| Boot readiness timeout | 180 s |
|
|
121
|
+
| Health poll interval | 500 ms |
|
|
122
|
+
| Fan mode | `smart` (persisted choice wins; set via `/mtplx → Fan Curves`) |
|
|
123
|
+
|
|
124
|
+
Persisted state (all under `~/.pi/agent/`):
|
|
125
|
+
|
|
126
|
+
- `mtplx-fanmode.json` — chosen fan mode.
|
|
127
|
+
- `mtplx-models.json` — model id → artifact ref registry.
|
|
128
|
+
- `models.json` — Pi's catalog; pi-mtplx manages only the `mtplx` provider entry.
|
|
129
|
+
|
|
130
|
+
To change the port or profile, edit `src/utils.ts` / `src/mtplx-process.ts` — these are the only two constants the whole package depends on.
|
|
131
|
+
|
|
132
|
+
## Troubleshooting
|
|
133
|
+
|
|
134
|
+
- **"MTPLX cannot use 127.0.0.1:8000: another, non-MTPLX service is listening there."** — something else owns the port. Stop it, or change `PORT` in `src/utils.ts`.
|
|
135
|
+
- **"MTPLX startup failed … timed out after 180s"** — check `mtplx status --deep` for MTPLX-side diagnostics (model validation, memory, thermal).
|
|
136
|
+
- **"MTPLX model … is not mapped to an installed MTPLX artifact"** — the model id isn't in `~/.pi/agent/mtplx-models.json`; register it via `/mtplx → Models`.
|
|
137
|
+
- **Model registered but `/model` doesn't list it** — run `/reload` (or restart Pi) after registration; `models.json` is read at load time.
|
|
138
|
+
- **Fan mode change didn't apply to a running server** — the MTPLX thermal endpoint requires the server to be healthy; if `/health` fails, the new fan mode applies at next boot.
|
|
139
|
+
- **`mtplx: command not found`** — make sure the PATH Pi runs under includes the directory with `mtplx` (`which mtplx`).
|
|
140
|
+
- **Uninstalling** — `/mtplx → Uninstall` removes the `mtplx` provider from `models.json`; the registry file and installed models stay. Then remove the package from Pi.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
git clone https://github.com/KrossKinetic/pi-mtplx
|
|
146
|
+
cd pi-mtplx
|
|
147
|
+
npm install
|
|
148
|
+
npm run typecheck # tsc --noEmit
|
|
149
|
+
npm test # node:test via tsx (tests for the pure helpers)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
To load the extension locally without publishing, point Pi at it as a package in a test directory, or symlink `extensions/mtplx.ts` and `extensions/mtplx-tkps-footer.ts` into `~/.pi/agent/extensions/` and `/reload`.
|
|
153
|
+
|
|
154
|
+
## Publishing
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm version <next> # or edit package.json
|
|
158
|
+
npm login
|
|
159
|
+
npm publish # name is unscoped, so no --access flag needed
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
For GitHub, push the `main` branch and users can `pi install git:github.com/KrossKinetic/pi-mtplx`. The package is tagged `pi-package` in `keywords`, so it shows up in the [package gallery](https://pi.dev/packages).
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tk/s Extension — MTPLX
|
|
3
|
+
*
|
|
4
|
+
* Reports the token generation rate of the LAST completed assistant turn as a
|
|
5
|
+
* status segment ("⚡NN.N tk/s"). pi-zentui's footer merges extension statuses
|
|
6
|
+
* (same mechanism pi-lens uses for "LSP Inactive"), so this shows up in the
|
|
7
|
+
* existing zentui footer without fighting setFooter ownership.
|
|
8
|
+
*
|
|
9
|
+
* tk/s = usage.output of the last assistant message / (message.timestamp -
|
|
10
|
+
* stream start), where stream start is captured on message_start (assistant).
|
|
11
|
+
*/
|
|
12
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
const STATUS_KEY = "tkps";
|
|
16
|
+
|
|
17
|
+
let streamStartMs: number | undefined;
|
|
18
|
+
|
|
19
|
+
export default function (pi: ExtensionAPI) {
|
|
20
|
+
pi.on("message_start", (event) => {
|
|
21
|
+
if (event.message.role === "assistant") {
|
|
22
|
+
streamStartMs = Date.now();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
pi.on("message_end", (event, ctx) => {
|
|
27
|
+
const m = event.message;
|
|
28
|
+
if (m.role !== "assistant") return;
|
|
29
|
+
const msg = m as AssistantMessage;
|
|
30
|
+
if (streamStartMs !== undefined && msg.usage?.output > 0) {
|
|
31
|
+
// msg.timestamp is set by pi-ai at STREAM START, not end — the end time
|
|
32
|
+
// must be captured here. (Using msg.timestamp yields a clamped 0.01s
|
|
33
|
+
// duration and bogus tk/s.)
|
|
34
|
+
const durationSec = Math.max((Date.now() - streamStartMs) / 1000, 0.01);
|
|
35
|
+
const rate = msg.usage.output / durationSec;
|
|
36
|
+
ctx.ui.setStatus(STATUS_KEY, `⚡${rate.toFixed(1)} tk/s`);
|
|
37
|
+
streamStartMs = undefined;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-mtplx — zero-config MTPLX integration for the Pi coding agent.
|
|
3
|
+
*
|
|
4
|
+
* Pi package entry point: wires the MTPLX lifecycle (src/mtplx-process.ts),
|
|
5
|
+
* HTTP client (src/mtplx-client.ts), model registry (src/model-discovery.ts)
|
|
6
|
+
* and shared helpers (src/utils.ts) into Pi via before_agent_start,
|
|
7
|
+
* agent_end and session_shutdown, plus the /mtplx command.
|
|
8
|
+
*/
|
|
9
|
+
import { acquire, release, stopServer } from "../src/mtplx-process.ts";
|
|
10
|
+
import { getFanMode, health, setFanMode, setFanModeValue } from "../src/mtplx-client.ts";
|
|
11
|
+
import { MTPLX_PROVIDER, listModels, removePiMtplxProvider } from "../src/model-discovery.ts";
|
|
12
|
+
import { FAN_MODES, isMtplxModel, saveFanMode, type FanMode } from "../src/utils.ts";
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
export default function mtplxAutostart(pi: ExtensionAPI): void {
|
|
16
|
+
pi.registerCommand("mtplx", {
|
|
17
|
+
description: "MTPLX — toggle the server, pick a fan curve, or register a model",
|
|
18
|
+
handler: async (_args, ctx) => {
|
|
19
|
+
const current = await health();
|
|
20
|
+
const status = current ? "on" : "off";
|
|
21
|
+
await ctx.ui.setStatus("mtplx", `MTPLX: ${status}`);
|
|
22
|
+
const topChoices = [`Toggle (${status})`, `Fan Curves (current: ${getFanMode()})`, `Models (register)`, "Uninstall (remove pi models.json entry)"];
|
|
23
|
+
const top = await ctx.ui.select("MTPLX", topChoices, undefined);
|
|
24
|
+
if (!top) return;
|
|
25
|
+
if (top.startsWith("Toggle")) {
|
|
26
|
+
if (current) {
|
|
27
|
+
await stopServer();
|
|
28
|
+
ctx.ui.notify("MTPLX server stopped", "info");
|
|
29
|
+
} else if (ctx.model && isMtplxModel(ctx.model)) {
|
|
30
|
+
await acquire(ctx.model.id);
|
|
31
|
+
ctx.ui.notify(`MTPLX server started (${ctx.model.id})`, "info");
|
|
32
|
+
} else {
|
|
33
|
+
ctx.ui.notify(`MTPLX not started — switch to an MTPLX (${MTPLX_PROVIDER}) model (active: ${ctx.model?.provider}/${ctx.model?.id})`, "warning");
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (top.startsWith("Fan Curves")) {
|
|
38
|
+
const choices = FAN_MODES.map((mode) => (mode === getFanMode() ? `${mode} (current)` : mode));
|
|
39
|
+
const picked = await ctx.ui.select("MTPLX — fan mode for autostart", choices, undefined);
|
|
40
|
+
if (!picked) return;
|
|
41
|
+
const mode = picked.replace(" (current)", "") as FanMode;
|
|
42
|
+
if (!(FAN_MODES as readonly string[]).includes(mode)) return;
|
|
43
|
+
setFanModeValue(mode);
|
|
44
|
+
saveFanMode(mode);
|
|
45
|
+
if (current) {
|
|
46
|
+
await setFanMode();
|
|
47
|
+
}
|
|
48
|
+
ctx.ui.notify(`MTPLX fan mode set to ${getFanMode()}`, "info");
|
|
49
|
+
}
|
|
50
|
+
if (top.startsWith("Models")) {
|
|
51
|
+
await listModels(ctx);
|
|
52
|
+
}
|
|
53
|
+
if (top.startsWith("Uninstall")) {
|
|
54
|
+
const ok = await ctx.ui.confirm(
|
|
55
|
+
"pi-mtplx uninstall",
|
|
56
|
+
`Remove the ${MTPLX_PROVIDER} provider (and its models) from ~/.pi/agent/models.json? The model registry file (~/.pi/agent/mtplx-models.json) and installed MTPLX models are left in place.`,
|
|
57
|
+
undefined,
|
|
58
|
+
);
|
|
59
|
+
if (!ok) return;
|
|
60
|
+
if (removePiMtplxProvider()) {
|
|
61
|
+
ctx.ui.notify(`Removed ${MTPLX_PROVIDER} provider from models.json. Run /reload (or restart Pi), then remove the pi-mtplx package.`, "info");
|
|
62
|
+
} else {
|
|
63
|
+
ctx.ui.notify(`Nothing to remove — the ${MTPLX_PROVIDER} provider is not in models.json.`, "info");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
70
|
+
if (!ctx.model || !isMtplxModel(ctx.model)) return;
|
|
71
|
+
try {
|
|
72
|
+
await acquire(ctx.model.id);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new Error(`MTPLX request blocked: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
79
|
+
if (ctx.model && isMtplxModel(ctx.model)) release();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
pi.on("session_shutdown", async (event) => {
|
|
83
|
+
if (event.reason !== "quit") return;
|
|
84
|
+
try {
|
|
85
|
+
await stopServer();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error(`MTPLX cleanup on quit failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-mtplx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-config MTPLX integration for Pi coding agent",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-agent",
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"mtplx",
|
|
10
|
+
"local-llm",
|
|
11
|
+
"local-inference"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"homepage": "https://github.com/KrossKinetic/pi-mtplx#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/KrossKinetic/pi-mtplx.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/KrossKinetic/pi-mtplx/issues"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./extensions/mtplx.ts",
|
|
29
|
+
"./extensions/mtplx-tkps-footer.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
34
|
+
"@earendil-works/pi-ai": "*"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@earendil-works/pi-ai": "0.84.2",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "0.84.2",
|
|
39
|
+
"@types/node": "^22",
|
|
40
|
+
"tsx": "^4",
|
|
41
|
+
"typescript": "^5"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"extensions",
|
|
45
|
+
"src",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"test": "tsx --test test/*.test.ts"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MTPLX model registry: Pi model id → installed MTPLX artifact ref.
|
|
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.
|
|
6
|
+
*
|
|
7
|
+
* The built-in map is the fallback; registrations added from `/mtplx` are
|
|
8
|
+
* persisted next to it (~/.pi/agent/mtplx-models.json) and override it at load.
|
|
9
|
+
*
|
|
10
|
+
* Pi's model catalog is the USER's own ~/.pi/agent/models.json — a provider
|
|
11
|
+
* config that pre-existed this package (created by `mtplx start pi` / the
|
|
12
|
+
* /mtplx UI). The canonical provider name is MTPLX's own `mtplx`
|
|
13
|
+
* (PI_PROVIDER_ID in MTPLX's mtplx/pi.py); this extension reads and writes
|
|
14
|
+
* only that provider entry and leaves every other provider untouched.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { modelIdFromRef, displayNameFromId, slugFromId } from "./utils.ts";
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
// Matches MTPLX's own PI_PROVIDER_ID ("mtplx") so the extension operates on
|
|
27
|
+
// the exact provider block that `mtplx start pi` creates.
|
|
28
|
+
export const MTPLX_PROVIDER = "mtplx";
|
|
29
|
+
const MODELS_FILE = join(homedir(), ".pi", "agent", "mtplx-models.json");
|
|
30
|
+
|
|
31
|
+
const BUILTIN_MODELS: Record<string, { ref: string }> = {
|
|
32
|
+
"mtplx-qwen38-27b-optimized-quality": {
|
|
33
|
+
ref: "Youssofal/Qwen3.8-27B-MTPLX-Optimized-Quality",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function loadRegisteredModels(): Record<string, { ref: string }> {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(MODELS_FILE, "utf8")) as Record<string, { ref?: unknown }>;
|
|
40
|
+
const out: Record<string, { ref: string }> = {};
|
|
41
|
+
for (const [id, entry] of Object.entries(parsed)) {
|
|
42
|
+
if (entry && typeof entry.ref === "string") out[id] = { ref: entry.ref };
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
} catch {
|
|
46
|
+
// missing or corrupt file → fall back to the built-ins only
|
|
47
|
+
}
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const MTPLX_MODELS: Record<string, { ref: string }> = { ...BUILTIN_MODELS, ...loadRegisteredModels() };
|
|
52
|
+
|
|
53
|
+
export function saveRegisteredModels(): void {
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
56
|
+
writeFileSync(MODELS_FILE, JSON.stringify(MTPLX_MODELS, null, 2) + "\n");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`MTPLX could not persist model registry: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type MtplxListedModel = { repo_id?: unknown; path?: unknown; name?: unknown };
|
|
63
|
+
|
|
64
|
+
export async function listMtplxModels(): Promise<MtplxListedModel[]> {
|
|
65
|
+
try {
|
|
66
|
+
const { stdout } = await execFileAsync("mtplx", ["list", "--json"], { timeout: 30_000 });
|
|
67
|
+
const parsed = JSON.parse(stdout) as { models?: unknown };
|
|
68
|
+
const models = parsed.models;
|
|
69
|
+
return Array.isArray(models) ? (models as MtplxListedModel[]) : [];
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function listedIdentity(model: MtplxListedModel): string {
|
|
76
|
+
return typeof model.repo_id === "string" && model.repo_id ? model.repo_id : typeof model.path === "string" ? model.path : "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ask the user which MTPLX models are installed, then register the pick into
|
|
81
|
+
* the mapping file and into the `mtplx` provider of the user's models.json
|
|
82
|
+
* catalog. Every other provider is left untouched.
|
|
83
|
+
*/
|
|
84
|
+
export async function listModels(ctx: ExtensionContext): Promise<void> {
|
|
85
|
+
const installed = await listMtplxModels();
|
|
86
|
+
if (installed.length === 0) {
|
|
87
|
+
ctx.ui.notify("No MTPLX models found. Install one with `mtplx install`.", "warning");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const choices = installed.map((model) => {
|
|
91
|
+
const ref = listedIdentity(model);
|
|
92
|
+
const refId = modelIdFromRef(ref);
|
|
93
|
+
const existingId = Object.keys(MTPLX_MODELS).find((id) => MTPLX_MODELS[id].ref === ref || slugFromId(id) === slugFromId(refId));
|
|
94
|
+
const id = existingId ?? refId;
|
|
95
|
+
const mark = existingId ? "✓" : "✗";
|
|
96
|
+
return `${mark} ${id} — ${ref}`;
|
|
97
|
+
});
|
|
98
|
+
choices.push("Cancel");
|
|
99
|
+
const picked = await ctx.ui.select("MTPLX models — ✓ registered in Pi · ✗ available in MTPLX — run /reload after making changes", choices, undefined);
|
|
100
|
+
if (!picked || picked === "Cancel") return;
|
|
101
|
+
const ref = picked.split(" — ").slice(1).join(" — ");
|
|
102
|
+
const modelId = Object.keys(MTPLX_MODELS).find((id) => MTPLX_MODELS[id].ref === ref) ?? modelIdFromRef(ref);
|
|
103
|
+
if (MTPLX_MODELS[modelId]) {
|
|
104
|
+
ctx.ui.notify(`${modelId} is already registered — run /reload, then switch with /model.`, "info");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 1) Persist the Pi id → artifact ref mapping (also used to resolve autostart model ids).
|
|
109
|
+
MTPLX_MODELS[modelId] = { ref };
|
|
110
|
+
saveRegisteredModels();
|
|
111
|
+
|
|
112
|
+
// 2) Register the model in the `mtplx` provider of Pi's catalog (models.json).
|
|
113
|
+
try {
|
|
114
|
+
const modelsJsonPath = join(homedir(), ".pi", "agent", "models.json");
|
|
115
|
+
const catalog = JSON.parse(readFileSync(modelsJsonPath, "utf8")) as { providers?: Record<string, unknown> };
|
|
116
|
+
const providers = (catalog.providers ??= {});
|
|
117
|
+
const provider = (providers[MTPLX_PROVIDER] ??= {
|
|
118
|
+
api: "openai-completions",
|
|
119
|
+
apiKey: "mtplx-local",
|
|
120
|
+
authHeader: true,
|
|
121
|
+
baseUrl: "http://127.0.0.1:8000/v1",
|
|
122
|
+
compat: {
|
|
123
|
+
maxTokensField: "max_tokens",
|
|
124
|
+
supportsDeveloperRole: false,
|
|
125
|
+
supportsReasoningEffort: true,
|
|
126
|
+
},
|
|
127
|
+
headers: {
|
|
128
|
+
"x-mtplx-client": "pi",
|
|
129
|
+
},
|
|
130
|
+
}) as { models?: unknown[] };
|
|
131
|
+
const models = Array.isArray(provider.models) ? (provider.models as unknown[]) : [];
|
|
132
|
+
if (models.some((entry) => (entry as { id?: unknown }).id === modelId)) {
|
|
133
|
+
ctx.ui.notify(`Already in models.json: ${modelId}`, "warning");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
models.push({
|
|
137
|
+
id: modelId,
|
|
138
|
+
name: displayNameFromId(modelId),
|
|
139
|
+
api: "openai-completions",
|
|
140
|
+
reasoning: true,
|
|
141
|
+
input: ["text", "image"],
|
|
142
|
+
contextWindow: 262144,
|
|
143
|
+
maxTokens: 65536,
|
|
144
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
145
|
+
thinkingLevelMap: {
|
|
146
|
+
off: null,
|
|
147
|
+
minimal: null,
|
|
148
|
+
low: "low",
|
|
149
|
+
medium: "medium",
|
|
150
|
+
high: null,
|
|
151
|
+
xhigh: "xhigh",
|
|
152
|
+
max: null,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
provider.models = models;
|
|
156
|
+
writeFileSync(modelsJsonPath, JSON.stringify(catalog, null, 2) + "\n");
|
|
157
|
+
} catch (error) {
|
|
158
|
+
ctx.ui.notify(`MTPLX model mapping saved, but models.json update failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
ctx.ui.notify(`Registered ${modelId} → ${ref}. Restart Pi, then switch to it with /model.`, "info");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Remove the `mtplx` provider from the user's models.json. Never touches any
|
|
166
|
+
* other provider. Silently succeeds when the file or provider is absent.
|
|
167
|
+
*/
|
|
168
|
+
export function removePiMtplxProvider(): boolean {
|
|
169
|
+
const modelsJsonPath = join(homedir(), ".pi", "agent", "models.json");
|
|
170
|
+
if (!existsSync(modelsJsonPath)) return false;
|
|
171
|
+
try {
|
|
172
|
+
const catalog = JSON.parse(readFileSync(modelsJsonPath, "utf8")) as { providers?: Record<string, unknown> };
|
|
173
|
+
if (!catalog.providers || !(MTPLX_PROVIDER in catalog.providers)) return false;
|
|
174
|
+
delete catalog.providers[MTPLX_PROVIDER];
|
|
175
|
+
writeFileSync(modelsJsonPath, JSON.stringify(catalog, null, 2) + "\n");
|
|
176
|
+
return true;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
console.error(`pi-mtplx uninstall could not update models.json: ${error instanceof Error ? error.message : String(error)}`);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal HTTP client for the locally-running MTPLX server (OpenAI-compatible
|
|
3
|
+
* endpoint on 127.0.0.1:8000). Only touches endpoints that MTPLX exposes:
|
|
4
|
+
* GET /health
|
|
5
|
+
* POST /v1/mtplx/thermal/fan_mode
|
|
6
|
+
*/
|
|
7
|
+
import { HOST, PORT, type FanMode, loadFanMode } from "./utils.ts";
|
|
8
|
+
|
|
9
|
+
export type Health = { ok: true; model: string; model_path?: string; fan_mode?: string };
|
|
10
|
+
|
|
11
|
+
// Current fan curve for autostart and live updates. Loaded from disk so the
|
|
12
|
+
// choice survives Pi restarts (see FANMODE_FILE in utils.ts).
|
|
13
|
+
let fanMode: FanMode = loadFanMode();
|
|
14
|
+
|
|
15
|
+
export function getFanMode(): FanMode {
|
|
16
|
+
return fanMode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setFanModeValue(mode: FanMode): void {
|
|
20
|
+
fanMode = mode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function health(): Promise<Health | undefined> {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timeout = setTimeout(() => controller.abort(), 1_500);
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(`http://${HOST}:${PORT}/health`, { signal: controller.signal });
|
|
28
|
+
if (!response.ok) return undefined;
|
|
29
|
+
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;
|
|
31
|
+
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,
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
} finally {
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function setFanMode(): Promise<void> {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
47
|
+
try {
|
|
48
|
+
const response = await fetch(`http://${HOST}:${PORT}/v1/mtplx/thermal/fan_mode`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "content-type": "application/json" },
|
|
51
|
+
body: JSON.stringify({ mode: fanMode }),
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
});
|
|
54
|
+
const body = (await response.json()) as { verified?: unknown; current_mode?: unknown; error?: unknown };
|
|
55
|
+
if (!response.ok || body.verified !== true || body.current_mode !== fanMode) {
|
|
56
|
+
throw new Error(`MTPLX fan mode could not be set to ${fanMode}: ${typeof body.error === "string" ? body.error : "unverified response"}`);
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(`MTPLX fan control failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timeout);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MTPLX process lifecycle: start / stop / ensure, with ownership tracking.
|
|
3
|
+
*
|
|
4
|
+
* Ownership model (important — see README):
|
|
5
|
+
* - This module spawns the server itself (`mtplx quickstart ...`), keeps a
|
|
6
|
+
* reference to the child, and passes `--model-id` so `/health` reports
|
|
7
|
+
* Pi's model id — a positive fingerprint that a given healthy server on
|
|
8
|
+
* the port is one this extension owns.
|
|
9
|
+
* - Cleanup is therefore precise: a server only gets shut down if the
|
|
10
|
+
* extension spawned it in this session, or if `/health` positively
|
|
11
|
+
* identifies it as an MTPLX server serving one of the extension's model
|
|
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.
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn, execFile } from "node:child_process";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
import type { ChildProcess } from "node:child_process";
|
|
22
|
+
import { getFanMode, health, setFanMode, type Health } from "./mtplx-client.ts";
|
|
23
|
+
import { MTPLX_MODELS } from "./model-discovery.ts";
|
|
24
|
+
import { HOST, PORT, READY_TIMEOUT_MS, POLL_MS, sleep, commandError, portIsOccupied } from "./utils.ts";
|
|
25
|
+
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Child process handle of the server this Pi session spawned, if any.
|
|
30
|
+
* Kept in parallel with the /health fingerprint: the handle covers "we
|
|
31
|
+
* spawned it", the fingerprint covers "a previous Pi session spawned it".
|
|
32
|
+
*/
|
|
33
|
+
let ownedChild: ChildProcess | undefined;
|
|
34
|
+
|
|
35
|
+
/** True when the extension spawned the currently-healthy server in this session. */
|
|
36
|
+
export function isOwnedByThisSession(): boolean {
|
|
37
|
+
return ownedChild !== undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function startupError(cause: Error): Error {
|
|
41
|
+
return new Error(`MTPLX startup failed: ${cause.message}. Check \`mtplx status --deep\` for MTPLX diagnostics.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shut down the server on HOST:PORT — but only if we can positively identify
|
|
46
|
+
* it as MTPLX (via /health). A healthy, non-MTPLX listener is an error, never
|
|
47
|
+
* a kill target.
|
|
48
|
+
*/
|
|
49
|
+
export async function stopServer(): Promise<void> {
|
|
50
|
+
const current = await health();
|
|
51
|
+
if (!current) {
|
|
52
|
+
if (await portIsOccupied()) {
|
|
53
|
+
throw new Error(`MTPLX cannot use ${HOST}:${PORT}: another, non-MTPLX service is listening there.`);
|
|
54
|
+
}
|
|
55
|
+
// Nothing listening (or not MTPLX): nothing to stop. Drop any stale handle.
|
|
56
|
+
ownedChild = undefined;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// /health says an MTPLX server is answering here.
|
|
61
|
+
if (!isOwnedByThisSession() && !currentModelIsOurs(current.model)) {
|
|
62
|
+
// An MTPLX server the extension does not own (e.g. started manually by
|
|
63
|
+
// the user). MTPLX exposes no reliable way to distinguish it from a
|
|
64
|
+
// Pi-managed one at the port level beyond --model-id, so do not kill
|
|
65
|
+
// it. If a Pi-owned one is still needed, the user can run /mtplx →
|
|
66
|
+
// Toggle.
|
|
67
|
+
console.warn(
|
|
68
|
+
`pi-mtplx: leaving MTPLX server on ${HOST}:${PORT} (model ${JSON.stringify(current.model)}) untouched — not owned by this Pi session. Use /mtplx → Toggle to stop it.`,
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
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)}`);
|
|
77
|
+
}
|
|
78
|
+
ownedChild = undefined;
|
|
79
|
+
|
|
80
|
+
const deadline = Date.now() + 20_000;
|
|
81
|
+
while (Date.now() < deadline) {
|
|
82
|
+
if (!(await health())) return;
|
|
83
|
+
await sleep(POLL_MS);
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`MTPLX shutdown timed out; ${HOST}:${PORT} is still healthy.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Positive ownership fingerprint: /health model id belongs to this extension's registry. */
|
|
89
|
+
function currentModelIsOurs(model: string): boolean {
|
|
90
|
+
return Object.keys(MTPLX_MODELS).includes(model);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Spawn the MTPLX server for a registered model and wait until /health
|
|
95
|
+
* confirms it serves exactly that model. The child is spawned detached so it
|
|
96
|
+
* outlives Pi's event-loop teardown, and unref'd so it never blocks Pi exit.
|
|
97
|
+
*/
|
|
98
|
+
export async function startServer(modelId: string): Promise<void> {
|
|
99
|
+
const configured = MTPLX_MODELS[modelId];
|
|
100
|
+
if (!configured) {
|
|
101
|
+
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
|
+
}
|
|
103
|
+
|
|
104
|
+
let exited: Error | undefined;
|
|
105
|
+
const child = spawn(
|
|
106
|
+
"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
|
+
// Keep the KV-cache SSD cold tier off (CLI default is `on`). Do not remove.
|
|
122
|
+
"--ssd-session-cache",
|
|
123
|
+
"off",
|
|
124
|
+
],
|
|
125
|
+
{ detached: true, stdio: "ignore" },
|
|
126
|
+
);
|
|
127
|
+
ownedChild = child;
|
|
128
|
+
child.once("error", (error) => {
|
|
129
|
+
exited = new Error(`could not launch mtplx: ${error.message}`);
|
|
130
|
+
});
|
|
131
|
+
child.once("exit", (code, signal) => {
|
|
132
|
+
if (code !== 0) exited = new Error(`mtplx exited before becoming ready (code ${code ?? "none"}, signal ${signal ?? "none"})`);
|
|
133
|
+
});
|
|
134
|
+
child.unref();
|
|
135
|
+
|
|
136
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
137
|
+
while (Date.now() < deadline) {
|
|
138
|
+
if (exited) throw startupError(exited);
|
|
139
|
+
const current = await health();
|
|
140
|
+
if (current?.model === modelId) {
|
|
141
|
+
if (current.fan_mode !== getFanMode()) await setFanMode();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (current) throw new Error(`MTPLX became healthy with ${JSON.stringify(current.model)}, not the requested ${JSON.stringify(modelId)}.`);
|
|
145
|
+
await sleep(POLL_MS);
|
|
146
|
+
}
|
|
147
|
+
throw startupError(new Error(`timed out after ${READY_TIMEOUT_MS / 1000}s`));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Ensure the server on HOST:PORT serves `modelId`, transparently switching
|
|
152
|
+
* models: stop the current (identified) server, then start the requested one.
|
|
153
|
+
*/
|
|
154
|
+
export async function ensureServer(modelId: string): Promise<void> {
|
|
155
|
+
const current = await health();
|
|
156
|
+
if (current?.model === modelId) {
|
|
157
|
+
if (current.fan_mode !== getFanMode()) await setFanMode();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (current) await stopServer();
|
|
161
|
+
else if (await portIsOccupied()) {
|
|
162
|
+
throw new Error(`MTPLX cannot start because ${HOST}:${PORT} is occupied by a non-MTPLX service.`);
|
|
163
|
+
}
|
|
164
|
+
await startServer(modelId);
|
|
165
|
+
await setFanMode();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let transition: Promise<void> | undefined;
|
|
169
|
+
let transitionModel: string | undefined;
|
|
170
|
+
let activeModel: string | undefined;
|
|
171
|
+
let activeAgents = 0;
|
|
172
|
+
let idleWaiters: Array<() => void> = [];
|
|
173
|
+
|
|
174
|
+
export function ensureOnce(modelId: string): Promise<void> {
|
|
175
|
+
if (transition) {
|
|
176
|
+
if (transitionModel === modelId) return transition;
|
|
177
|
+
return transition.then(() => ensureOnce(modelId));
|
|
178
|
+
}
|
|
179
|
+
transitionModel = modelId;
|
|
180
|
+
transition = ensureServer(modelId).finally(() => {
|
|
181
|
+
transition = undefined;
|
|
182
|
+
transitionModel = undefined;
|
|
183
|
+
});
|
|
184
|
+
return transition;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function acquire(modelId: string): Promise<void> {
|
|
188
|
+
// Never replace a model while an already-admitted MTPLX agent is running.
|
|
189
|
+
// Same-model requests share both this lease and any in-flight start Promise.
|
|
190
|
+
while (activeAgents > 0 && activeModel !== modelId) {
|
|
191
|
+
await new Promise<void>((resolve) => idleWaiters.push(resolve));
|
|
192
|
+
}
|
|
193
|
+
// Reserve the model before awaiting startup. This closes the gap where a
|
|
194
|
+
// different request could otherwise begin a replacement between readiness
|
|
195
|
+
// and this request entering Pi's provider path.
|
|
196
|
+
activeModel = modelId;
|
|
197
|
+
activeAgents += 1;
|
|
198
|
+
try {
|
|
199
|
+
await ensureOnce(modelId);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
release();
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function release(): void {
|
|
207
|
+
if (activeAgents === 0) return;
|
|
208
|
+
activeAgents -= 1;
|
|
209
|
+
if (activeAgents === 0) {
|
|
210
|
+
activeModel = undefined;
|
|
211
|
+
for (const resolve of idleWaiters.splice(0)) resolve();
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants and small helpers for the pi-mtplx extension.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { createConnection } from "node:net";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export const HOST = "127.0.0.1";
|
|
10
|
+
export const PORT = 8000;
|
|
11
|
+
export const READY_TIMEOUT_MS = 180_000;
|
|
12
|
+
export const POLL_MS = 500;
|
|
13
|
+
|
|
14
|
+
export type FanMode = "default" | "smart" | "max";
|
|
15
|
+
export const FAN_MODES: readonly FanMode[] = ["default", "smart", "max"];
|
|
16
|
+
|
|
17
|
+
// Fan mode ("fan curve") applied at autostart, live-updated from `/mtplx` while the
|
|
18
|
+
// server runs. Persisted to disk so the choice survives Pi restarts: `fanMode` is a
|
|
19
|
+
// module-level variable that would reset to "smart" on every fresh session, silently
|
|
20
|
+
// overriding the previous `/mtplx` pick (and mislabelling "(current)" on the wrong mode).
|
|
21
|
+
export const FANMODE_FILE = join(homedir(), ".pi", "agent", "mtplx-fanmode.json");
|
|
22
|
+
|
|
23
|
+
export function loadFanMode(): FanMode {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(readFileSync(FANMODE_FILE, "utf8")) as { fanMode?: unknown };
|
|
26
|
+
if (typeof parsed.fanMode === "string" && (FAN_MODES as readonly string[]).includes(parsed.fanMode)) {
|
|
27
|
+
return parsed.fanMode as FanMode;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// missing or corrupt file → fall back to the default
|
|
31
|
+
}
|
|
32
|
+
return "smart";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function saveFanMode(fanMode: FanMode): void {
|
|
36
|
+
try {
|
|
37
|
+
mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
38
|
+
writeFileSync(FANMODE_FILE, JSON.stringify({ fanMode }, null, 2) + "\n");
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error(`MTPLX could not persist fan mode: ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Derive Pi's model id from an artifact ref: "Youssofal/Qwen3.8-27B-MTPLX-Optimized-Quality"
|
|
45
|
+
// → "mtplx-qwen38-27b-optimized-quality" (same scheme as the built-in entry).
|
|
46
|
+
export function modelIdFromRef(ref: string): string {
|
|
47
|
+
const slug = ref
|
|
48
|
+
.split("/")
|
|
49
|
+
.pop() ?? ref
|
|
50
|
+
.replace(/^mtplx/i, "")
|
|
51
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
52
|
+
.replace(/-+/g, "-")
|
|
53
|
+
.replace(/^-|-$/g, "");
|
|
54
|
+
return `mtplx-${slug || "model"}`.toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function displayNameFromId(id: string): string {
|
|
58
|
+
return id
|
|
59
|
+
.replace(/^mtplx-/, "")
|
|
60
|
+
.replace(/-+/g, " ")
|
|
61
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function slugFromId(id: string): string {
|
|
65
|
+
return id
|
|
66
|
+
.replace(/^mtplx-/, "")
|
|
67
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
68
|
+
.replace(/-+/g, "-")
|
|
69
|
+
.replace(/^-|-$/g, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isMtplxModel(model: { provider: string; id: string } | undefined): boolean {
|
|
73
|
+
// The provider name is MTPLX's own PI_PROVIDER_ID (`mtplx`), matching the
|
|
74
|
+
// provider block that `mtplx start pi` writes to models.json.
|
|
75
|
+
return model?.provider === "mtplx";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function sleep(ms: number): Promise<void> {
|
|
79
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function commandError(error: unknown): string {
|
|
83
|
+
if (typeof error !== "object" || error === null) return String(error);
|
|
84
|
+
const details = error as { stderr?: string | Buffer; message?: string };
|
|
85
|
+
const stderr = details.stderr?.toString().trim();
|
|
86
|
+
return stderr || details.message || "unknown command failure";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function portIsOccupied(): Promise<boolean> {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const socket = createConnection({ host: HOST, port: PORT });
|
|
92
|
+
const done = (occupied: boolean) => {
|
|
93
|
+
socket.destroy();
|
|
94
|
+
resolve(occupied);
|
|
95
|
+
};
|
|
96
|
+
socket.setTimeout(1_000);
|
|
97
|
+
socket.once("connect", () => done(true));
|
|
98
|
+
socket.once("timeout", () => done(false));
|
|
99
|
+
socket.once("error", () => done(false));
|
|
100
|
+
});
|
|
101
|
+
}
|