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