localpi 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 +253 -0
- package/dist/src/cli/cli.js +57 -0
- package/dist/src/cli/main.js +12 -0
- package/dist/src/common/json.js +21 -0
- package/dist/src/common/result.js +9 -0
- package/dist/src/llm/openai.js +78 -0
- package/dist/src/llm/types.js +1 -0
- package/dist/src/localpi/catalog.js +191 -0
- package/dist/src/localpi/llama-server.js +505 -0
- package/dist/src/localpi/managed-runtime.js +225 -0
- package/dist/src/localpi/models.js +169 -0
- package/dist/src/localpi/options.js +240 -0
- package/dist/src/localpi/provider-registry.js +121 -0
- package/dist/src/localpi/runtime-connection.js +75 -0
- package/dist/src/localpi/runtime-selection.js +75 -0
- package/dist/src/localpi/runtime-types.js +1 -0
- package/dist/src/localpi/runtime.js +89 -0
- package/dist/src/pi/config.js +108 -0
- package/dist/src/pi/extensions.js +348 -0
- package/dist/src/pi/launch.js +64 -0
- package/docs/2026-06-15-model-catalog-implementation-plan.md +220 -0
- package/docs/2026-06-16-startup-model-and-thinking-control-plan.md +129 -0
- package/docs/implementation-plan.md +75 -0
- package/docs/runtime-specification.md +148 -0
- package/docs/structured-output.md +9 -0
- package/package.json +54 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export async function writeDefaultExtensions(options, extensionOptions = {}) {
|
|
4
|
+
const extensionDir = path.join(options.stateDir, "pi-extensions");
|
|
5
|
+
await mkdir(extensionDir, { recursive: true });
|
|
6
|
+
const paths = [];
|
|
7
|
+
if (extensionOptions.startupModelSelector !== undefined) {
|
|
8
|
+
paths.push(await writeExtension(extensionDir, "startup-model-selector.ts", startupModelSelectorExtensionSource(extensionOptions.startupModelSelector)));
|
|
9
|
+
}
|
|
10
|
+
paths.push(await writeExtension(extensionDir, "thinking-control.ts", thinkingControlExtensionSource()));
|
|
11
|
+
if (options.approval) {
|
|
12
|
+
paths.push(await writeExtension(extensionDir, "tool-approval.ts", approvalExtensionSource()));
|
|
13
|
+
}
|
|
14
|
+
if (options.tokenStatus) {
|
|
15
|
+
paths.push(await writeExtension(extensionDir, "token-status.ts", tokenStatusExtensionSource()));
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
paths,
|
|
19
|
+
systemPrompt: localpiSystemPrompt(options.approval)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function writeExtension(extensionDir, name, source) {
|
|
23
|
+
const extensionPath = path.join(extensionDir, name);
|
|
24
|
+
await writeFile(extensionPath, source, "utf8");
|
|
25
|
+
return extensionPath;
|
|
26
|
+
}
|
|
27
|
+
function startupModelSelectorExtensionSource(options) {
|
|
28
|
+
const startupModelsSource = JSON.stringify(options.models);
|
|
29
|
+
return `import type { ExtensionAPI, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { ModelSelectorComponent } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
|
|
32
|
+
type SelectedModel = Parameters<ExtensionAPI["setModel"]>[0];
|
|
33
|
+
const startupModels = ${startupModelsSource} as const;
|
|
34
|
+
const startupModelKeys = new Set(startupModels.map((model) => modelKey(model)));
|
|
35
|
+
|
|
36
|
+
export default function localpiStartupModelSelector(pi: ExtensionAPI): void {
|
|
37
|
+
let opened = false;
|
|
38
|
+
|
|
39
|
+
pi.on("session_start", async (event, ctx) => {
|
|
40
|
+
if (opened || event.reason !== "startup" || ctx.mode !== "tui") {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const selectableModels = startupAvailableModels(ctx.modelRegistry);
|
|
45
|
+
if (selectableModels.length <= 1) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const scopedModels = selectableModels.map((model) => ({ model }));
|
|
49
|
+
|
|
50
|
+
opened = true;
|
|
51
|
+
const selected = await ctx.ui.custom<SelectedModel | undefined>((tui, _theme, _keybindings, done) => {
|
|
52
|
+
const settings = {
|
|
53
|
+
setDefaultModelAndProvider: () => {}
|
|
54
|
+
} as unknown as SettingsManager;
|
|
55
|
+
return new ModelSelectorComponent(
|
|
56
|
+
tui,
|
|
57
|
+
ctx.model,
|
|
58
|
+
settings,
|
|
59
|
+
startupModelRegistry(ctx.modelRegistry) as typeof ctx.modelRegistry,
|
|
60
|
+
scopedModels,
|
|
61
|
+
(model) => done(model),
|
|
62
|
+
() => done(undefined)
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (selected === undefined) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ok = await pi.setModel(selected);
|
|
71
|
+
if (!ok) {
|
|
72
|
+
ctx.ui.notify(\`No API key for \${selected.provider}/\${selected.id}\`, "error");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function startupAvailableModels(registry: {
|
|
78
|
+
getAvailable(): SelectedModel[];
|
|
79
|
+
}): SelectedModel[] {
|
|
80
|
+
return registry.getAvailable().filter((model) => startupModelKeys.has(modelKey(model)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function startupModelRegistry(registry: {
|
|
84
|
+
refresh(): void;
|
|
85
|
+
getError(): string | undefined;
|
|
86
|
+
getAvailable(): SelectedModel[];
|
|
87
|
+
find(provider: string, modelId: string): SelectedModel | undefined;
|
|
88
|
+
}): typeof registry {
|
|
89
|
+
return {
|
|
90
|
+
refresh: () => registry.refresh(),
|
|
91
|
+
getError: () => registry.getError(),
|
|
92
|
+
getAvailable: () => startupAvailableModels(registry),
|
|
93
|
+
find: (provider, modelId) => {
|
|
94
|
+
const model = registry.find(provider, modelId);
|
|
95
|
+
return model !== undefined && startupModelKeys.has(modelKey(model)) ? model : undefined;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function modelKey(model: { readonly provider: string; readonly id: string }): string {
|
|
101
|
+
return \`\${model.provider}\\u0000\${model.id}\`;
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
function localpiSystemPrompt(approval) {
|
|
106
|
+
return [
|
|
107
|
+
"You are running through localpi, a local Pi launcher for local models.",
|
|
108
|
+
approval
|
|
109
|
+
? "Tool calls require user approval. If a tool result says it was blocked, denied, or requires approval, the tool did not run."
|
|
110
|
+
: "Tool approval is disabled for this session.",
|
|
111
|
+
"Do not claim that a blocked tool call ran.",
|
|
112
|
+
"Prefer answering directly when tools are not needed."
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
|
115
|
+
function approvalExtensionSource() {
|
|
116
|
+
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
117
|
+
|
|
118
|
+
export default function localpiToolApproval(pi: ExtensionAPI): void {
|
|
119
|
+
pi.on("before_agent_start", (event) => ({
|
|
120
|
+
systemPrompt:
|
|
121
|
+
event.systemPrompt +
|
|
122
|
+
"\\n\\nTool approval rule: if any tool result says the tool was blocked, denied, or requires approval, the tool did not run. Do not claim blocked tools ran."
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
126
|
+
const input = formatInput(event.input);
|
|
127
|
+
|
|
128
|
+
if (!ctx.hasUI) {
|
|
129
|
+
return {
|
|
130
|
+
block: true,
|
|
131
|
+
reason: \`Tool call "\${event.toolName}" was blocked and did not run because interactive approval is required.\`
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ok = await ctx.ui.confirm(\`Allow tool call: \${event.toolName}?\`, input);
|
|
136
|
+
if (!ok) {
|
|
137
|
+
return { block: true, reason: "Tool call was blocked by the user and did not run." };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return undefined;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatInput(input: unknown): string {
|
|
145
|
+
let text: string;
|
|
146
|
+
try {
|
|
147
|
+
text = JSON.stringify(input, null, 2);
|
|
148
|
+
} catch {
|
|
149
|
+
text = String(input);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const maxLength = 4000;
|
|
153
|
+
if (text.length <= maxLength) {
|
|
154
|
+
return text;
|
|
155
|
+
}
|
|
156
|
+
return \`\${text.slice(0, maxLength)}\\n... truncated ...\`;
|
|
157
|
+
}
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
function tokenStatusExtensionSource() {
|
|
161
|
+
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
162
|
+
|
|
163
|
+
type Usage = {
|
|
164
|
+
input?: number;
|
|
165
|
+
output?: number;
|
|
166
|
+
cacheRead?: number;
|
|
167
|
+
cacheWrite?: number;
|
|
168
|
+
totalTokens?: number;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
type TurnState = {
|
|
172
|
+
startedAt: number;
|
|
173
|
+
outputText: string;
|
|
174
|
+
estimatedOutputTokens: number;
|
|
175
|
+
lastStatusAt: number;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export default function localpiTokenStatus(pi: ExtensionAPI): void {
|
|
179
|
+
let currentTurn: TurnState | undefined;
|
|
180
|
+
|
|
181
|
+
pi.on("turn_start", () => {
|
|
182
|
+
currentTurn = {
|
|
183
|
+
startedAt: Date.now(),
|
|
184
|
+
outputText: "",
|
|
185
|
+
estimatedOutputTokens: 0,
|
|
186
|
+
lastStatusAt: 0
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
pi.on("message_update", (event, ctx) => {
|
|
191
|
+
const state = currentTurn;
|
|
192
|
+
if (!ctx.hasUI || state === undefined) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const update = textUpdateFromUnknown(event.assistantMessageEvent ?? event.message ?? event);
|
|
196
|
+
if (update.kind === "delta") {
|
|
197
|
+
state.outputText += update.text;
|
|
198
|
+
} else if (update.text.length > state.outputText.length) {
|
|
199
|
+
state.outputText = update.text;
|
|
200
|
+
}
|
|
201
|
+
state.estimatedOutputTokens = Math.ceil(state.outputText.length / 4);
|
|
202
|
+
if (Date.now() - state.lastStatusAt < 250) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
state.lastStatusAt = Date.now();
|
|
206
|
+
ctx.ui.setStatus("localpi-perf", ctx.ui.theme.fg("dim", statusText(state)));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
pi.on("turn_end", (event, ctx) => {
|
|
210
|
+
const state = currentTurn ?? {
|
|
211
|
+
startedAt: Date.now(),
|
|
212
|
+
outputText: "",
|
|
213
|
+
estimatedOutputTokens: 0,
|
|
214
|
+
lastStatusAt: 0
|
|
215
|
+
};
|
|
216
|
+
currentTurn = undefined;
|
|
217
|
+
|
|
218
|
+
if (!ctx.hasUI || event.message.role !== "assistant") {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const usage = event.message.usage as Usage | undefined;
|
|
223
|
+
const output = usage?.output ?? state.estimatedOutputTokens;
|
|
224
|
+
const input = usage?.input ?? 0;
|
|
225
|
+
const cacheRead = usage?.cacheRead ?? 0;
|
|
226
|
+
const cacheWrite = usage?.cacheWrite ?? 0;
|
|
227
|
+
const elapsedSeconds = elapsed(state);
|
|
228
|
+
const context = ctx.getContextUsage();
|
|
229
|
+
const contextText =
|
|
230
|
+
context && context.percent !== null
|
|
231
|
+
? \`ctx \${Math.round(context.percent)}%/\${Math.round(context.contextWindow / 1000)}k\`
|
|
232
|
+
: "ctx ?";
|
|
233
|
+
|
|
234
|
+
ctx.ui.setStatus(
|
|
235
|
+
"localpi-perf",
|
|
236
|
+
ctx.ui.theme.fg(
|
|
237
|
+
"dim",
|
|
238
|
+
[
|
|
239
|
+
\`\${(output / elapsedSeconds).toFixed(1)} tok/s\`,
|
|
240
|
+
\`out \${output}\`,
|
|
241
|
+
\`in \${input}\`,
|
|
242
|
+
cacheRead > 0 ? \`cache \${cacheRead}\` : undefined,
|
|
243
|
+
cacheWrite > 0 ? \`cw \${cacheWrite}\` : undefined,
|
|
244
|
+
\`\${elapsedSeconds.toFixed(1)}s\`,
|
|
245
|
+
contextText
|
|
246
|
+
]
|
|
247
|
+
.filter(Boolean)
|
|
248
|
+
.join(" | ")
|
|
249
|
+
)
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
254
|
+
if (ctx.hasUI) {
|
|
255
|
+
ctx.ui.setStatus("localpi-perf", "");
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function statusText(state: TurnState): string {
|
|
261
|
+
const elapsedSeconds = elapsed(state);
|
|
262
|
+
return \`\${(state.estimatedOutputTokens / elapsedSeconds).toFixed(1)} tok/s | out ~\${state.estimatedOutputTokens} | \${elapsedSeconds.toFixed(1)}s\`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function elapsed(state: TurnState): number {
|
|
266
|
+
return Math.max((Date.now() - state.startedAt) / 1000, 0.001);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
type TextUpdate = {
|
|
270
|
+
kind: "delta" | "snapshot";
|
|
271
|
+
text: string;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
function textUpdateFromUnknown(value: unknown): TextUpdate {
|
|
275
|
+
if (typeof value === "string") {
|
|
276
|
+
return { kind: "snapshot", text: value };
|
|
277
|
+
}
|
|
278
|
+
if (value && typeof value === "object") {
|
|
279
|
+
const object = value as Record<string, unknown>;
|
|
280
|
+
const delta = object["delta"];
|
|
281
|
+
const text = object["text"] ?? object["content"];
|
|
282
|
+
if (typeof delta === "string") {
|
|
283
|
+
return { kind: "delta", text: delta };
|
|
284
|
+
}
|
|
285
|
+
if (typeof text === "string") {
|
|
286
|
+
return { kind: "snapshot", text };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { kind: "snapshot", text: "" };
|
|
290
|
+
}
|
|
291
|
+
`;
|
|
292
|
+
}
|
|
293
|
+
function thinkingControlExtensionSource() {
|
|
294
|
+
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
295
|
+
|
|
296
|
+
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
297
|
+
|
|
298
|
+
const levels: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
299
|
+
|
|
300
|
+
export default function localpiThinkingControl(pi: ExtensionAPI): void {
|
|
301
|
+
pi.registerCommand("thinking", {
|
|
302
|
+
description: "Set localpi thinking level",
|
|
303
|
+
getArgumentCompletions: (prefix) => {
|
|
304
|
+
const trimmed = prefix.trim().toLowerCase();
|
|
305
|
+
const matches = levels.filter((level) => level.startsWith(trimmed));
|
|
306
|
+
return matches.length === 0 ? null : matches.map((level) => ({ value: level, label: level }));
|
|
307
|
+
},
|
|
308
|
+
handler: async (args, ctx) => {
|
|
309
|
+
const requested = parseThinkingLevel(args);
|
|
310
|
+
const level = requested ?? (await promptThinkingLevel(pi.getThinkingLevel(), ctx));
|
|
311
|
+
if (level === undefined) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
pi.setThinkingLevel(level);
|
|
315
|
+
const actual = pi.getThinkingLevel();
|
|
316
|
+
ctx.ui.notify(
|
|
317
|
+
actual === level ? \`thinking: \${actual}\` : \`thinking: \${actual} (clamped from \${level})\`,
|
|
318
|
+
actual === level ? "info" : "warning"
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
pi.on("thinking_level_select", (event, ctx) => {
|
|
324
|
+
ctx.ui.setStatus("localpi-thinking", \`thinking: \${event.level}\`);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
328
|
+
ctx.ui.setStatus("localpi-thinking", undefined);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function promptThinkingLevel(
|
|
333
|
+
current: ThinkingLevel,
|
|
334
|
+
ctx: { readonly ui: { select(title: string, options: string[]): Promise<string | undefined> } }
|
|
335
|
+
): Promise<ThinkingLevel | undefined> {
|
|
336
|
+
const selected = await ctx.ui.select(
|
|
337
|
+
"Thinking level",
|
|
338
|
+
levels.map((level) => (level === current ? \`\${level} (current)\` : level))
|
|
339
|
+
);
|
|
340
|
+
return selected === undefined ? undefined : parseThinkingLevel(selected);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parseThinkingLevel(value: string): ThinkingLevel | undefined {
|
|
344
|
+
const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase();
|
|
345
|
+
return levels.find((level) => level === normalized);
|
|
346
|
+
}
|
|
347
|
+
`;
|
|
348
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
export async function createLaunchPlan(options, runtimeConfig, connection, extensions) {
|
|
4
|
+
await mkdir(options.sessionDir, { recursive: true });
|
|
5
|
+
return {
|
|
6
|
+
command: options.piCommand,
|
|
7
|
+
args: [
|
|
8
|
+
"--provider",
|
|
9
|
+
connection.providerId,
|
|
10
|
+
"--model",
|
|
11
|
+
connection.model,
|
|
12
|
+
"--thinking",
|
|
13
|
+
options.thinking,
|
|
14
|
+
...extensionArgs(extensions),
|
|
15
|
+
"--append-system-prompt",
|
|
16
|
+
extensions.systemPrompt,
|
|
17
|
+
...withDefaultTools(options.forwardedArgs, options.tools)
|
|
18
|
+
],
|
|
19
|
+
env: {
|
|
20
|
+
PI_CODING_AGENT_DIR: runtimeConfig.configDir,
|
|
21
|
+
PI_CODING_AGENT_SESSION_DIR: options.sessionDir,
|
|
22
|
+
PI_OFFLINE: process.env["PI_OFFLINE"] ?? "1",
|
|
23
|
+
PI_TELEMETRY: process.env["PI_TELEMETRY"] ?? "0",
|
|
24
|
+
PI_SKIP_VERSION_CHECK: process.env["PI_SKIP_VERSION_CHECK"] ?? "1"
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export async function execLaunchPlan(plan) {
|
|
29
|
+
const stdio = "inherit";
|
|
30
|
+
const child = spawn(shellCommand(plan.command, plan.args), {
|
|
31
|
+
shell: true,
|
|
32
|
+
stdio,
|
|
33
|
+
env: { ...process.env, ...plan.env }
|
|
34
|
+
});
|
|
35
|
+
child.stdout?.resume();
|
|
36
|
+
return await new Promise((resolve, reject) => {
|
|
37
|
+
child.on("error", reject);
|
|
38
|
+
child.on("exit", (code, signal) => {
|
|
39
|
+
if (signal !== null) {
|
|
40
|
+
process.kill(process.pid, signal);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
resolve(code ?? 0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function shellCommand(command, args) {
|
|
48
|
+
return [command, ...args.map(shellQuote)].join(" ");
|
|
49
|
+
}
|
|
50
|
+
function shellQuote(value) {
|
|
51
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
52
|
+
}
|
|
53
|
+
function extensionArgs(extensions) {
|
|
54
|
+
return extensions.paths.flatMap((extensionPath) => ["--extension", extensionPath]);
|
|
55
|
+
}
|
|
56
|
+
function withDefaultTools(args, tools) {
|
|
57
|
+
if (tools === undefined || hasToolFlag(args)) {
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
return ["--tools", tools, ...args];
|
|
61
|
+
}
|
|
62
|
+
function hasToolFlag(args) {
|
|
63
|
+
return args.some((arg) => arg === "--tools" || arg === "-t" || arg === "--no-tools" || arg === "-nt");
|
|
64
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Model Catalog Implementation Plan
|
|
3
|
+
author: Bob <dutifulbob@gmail.com>
|
|
4
|
+
date: 2026-06-15
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Model Catalog Implementation Plan
|
|
8
|
+
|
|
9
|
+
Localpi should become a model catalog and launcher.
|
|
10
|
+
|
|
11
|
+
The goal is that plain `localpi` can show every usable local model, let the user pick one, and still give Pi enough model config for `/model` to switch among the same choices during the session.
|
|
12
|
+
|
|
13
|
+
## Target Behavior
|
|
14
|
+
|
|
15
|
+
- `localpi` discovers available local model providers before Pi starts.
|
|
16
|
+
- If exactly one usable model is available, localpi starts Pi with that model.
|
|
17
|
+
- If multiple usable models are available, localpi shows an interactive provider/model picker.
|
|
18
|
+
- If no external model is available, localpi can fall back to the managed `llama-server` default.
|
|
19
|
+
- Explicit `--provider` and `--model` flags bypass the startup picker.
|
|
20
|
+
- `--runtime vllm` should be accepted as a direct shortcut for the built-in vLLM provider.
|
|
21
|
+
- Pi receives a generated `models.json` containing every discovered usable model, not just the selected one.
|
|
22
|
+
- Pi receives a generated `settings.json` with the selected provider and model as the defaults.
|
|
23
|
+
- Pi `/model` can switch among the launch-time catalog entries without localpi-specific extension behavior.
|
|
24
|
+
|
|
25
|
+
The launch-time catalog is the first milestone. Live refresh after Pi has already started should be treated as a later Pi integration problem.
|
|
26
|
+
|
|
27
|
+
## Design Principles
|
|
28
|
+
|
|
29
|
+
- Keep model discovery, model selection, Pi config generation, and process management separate.
|
|
30
|
+
- Treat LM Studio, vLLM, managed `llama-server`, and future backends as provider adapters.
|
|
31
|
+
- Do not hide runtime side effects. Starting or stopping heavyweight model processes must be explicit or clearly prompted.
|
|
32
|
+
- Do not use Pi extensions to smuggle basic model inventory into Pi. Model inventory belongs in generated Pi config.
|
|
33
|
+
- Keep scripts deterministic. Non-interactive runs should not hang waiting for a picker.
|
|
34
|
+
|
|
35
|
+
## Catalog Model
|
|
36
|
+
|
|
37
|
+
Add a normalized catalog entry type.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
type CatalogModel = {
|
|
41
|
+
readonly providerId: string;
|
|
42
|
+
readonly providerName: string;
|
|
43
|
+
readonly runtime: "openai-compatible" | "managed-llama-server";
|
|
44
|
+
readonly baseUrl: string;
|
|
45
|
+
readonly modelId: string;
|
|
46
|
+
readonly displayName: string;
|
|
47
|
+
readonly contextWindow?: number;
|
|
48
|
+
readonly maxTokens?: number;
|
|
49
|
+
readonly capabilities: readonly ModelCapability[];
|
|
50
|
+
readonly availability: "loaded" | "startable";
|
|
51
|
+
};
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Use the catalog entry as the only data shape passed from discovery into selection and Pi config generation.
|
|
55
|
+
|
|
56
|
+
## Provider Registry
|
|
57
|
+
|
|
58
|
+
Add a provider registry that combines built-in providers with user config.
|
|
59
|
+
|
|
60
|
+
Built-in providers:
|
|
61
|
+
|
|
62
|
+
- `lmstudio`: OpenAI-compatible, default base URL `http://127.0.0.1:1234/v1`, discovery enabled.
|
|
63
|
+
- `vllm`: OpenAI-compatible, default base URL `http://127.0.0.1:8000/v1`, discovery enabled.
|
|
64
|
+
- `llama-server`: managed localpi runtime, exposes configured aliases as startable models.
|
|
65
|
+
|
|
66
|
+
Config-backed providers:
|
|
67
|
+
|
|
68
|
+
- Additional vLLM and other OpenAI-compatible servers should be configured by provider id and base URL.
|
|
69
|
+
- Localpi should not scan random ports for vLLM beyond the explicit built-in default.
|
|
70
|
+
- A provider config can opt into or out of discovery.
|
|
71
|
+
|
|
72
|
+
Example config:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"providers": {
|
|
77
|
+
"vllm-qwen": {
|
|
78
|
+
"type": "openai-compatible",
|
|
79
|
+
"name": "vLLM Qwen",
|
|
80
|
+
"baseUrl": "http://127.0.0.1:8000/v1",
|
|
81
|
+
"discover": true
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Provider Adapters
|
|
88
|
+
|
|
89
|
+
Create one adapter interface.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
type ProviderAdapter = {
|
|
93
|
+
readonly providerId: string;
|
|
94
|
+
discover(): Promise<readonly CatalogModel[]>;
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
OpenAI-compatible adapter:
|
|
99
|
+
|
|
100
|
+
- Probe `<baseUrl>/models`.
|
|
101
|
+
- Convert every reported model id into a `CatalogModel`.
|
|
102
|
+
- Preserve known context-window metadata when the endpoint reports it.
|
|
103
|
+
- Treat connection failures as unavailable provider results, not fatal startup errors, unless the user explicitly selected that provider.
|
|
104
|
+
|
|
105
|
+
Managed `llama-server` adapter:
|
|
106
|
+
|
|
107
|
+
- Include the currently served localpi-owned model as `loaded`.
|
|
108
|
+
- Include configured aliases as `startable` when their GGUF path exists.
|
|
109
|
+
- Keep existing memory-safety rules before starting a selected startable model.
|
|
110
|
+
|
|
111
|
+
## Selection Policy
|
|
112
|
+
|
|
113
|
+
Selection should happen after catalog discovery.
|
|
114
|
+
|
|
115
|
+
Interactive terminal:
|
|
116
|
+
|
|
117
|
+
- If there is more than one usable model and no explicit model was requested, show a numbered picker.
|
|
118
|
+
- Display provider and model together, for example `LM Studio / qwen3.6-35b-a3b-mtp`.
|
|
119
|
+
- Let Enter choose the first ranked model.
|
|
120
|
+
|
|
121
|
+
Non-interactive terminal:
|
|
122
|
+
|
|
123
|
+
- Do not prompt.
|
|
124
|
+
- Use a deterministic default.
|
|
125
|
+
- If the default is ambiguous, fail with a message that lists available `--provider` and `--model` values.
|
|
126
|
+
|
|
127
|
+
Explicit flags:
|
|
128
|
+
|
|
129
|
+
- `--provider <id> --model <id>` selects an exact catalog entry.
|
|
130
|
+
- `--model <provider>/<model>` can be added as a shorthand once provider ids are stable.
|
|
131
|
+
- `--runtime lmstudio` and `--runtime vllm` select those built-in external providers directly.
|
|
132
|
+
- Existing managed llama-server aliases should continue to work.
|
|
133
|
+
|
|
134
|
+
## Pi Config Generation
|
|
135
|
+
|
|
136
|
+
Change `writeRuntimeConfig` to receive the selected model and the full catalog.
|
|
137
|
+
|
|
138
|
+
Generated `models.json` should include one provider entry per catalog provider.
|
|
139
|
+
|
|
140
|
+
Generated `settings.json` should set:
|
|
141
|
+
|
|
142
|
+
- `defaultProvider` to the selected provider id
|
|
143
|
+
- `defaultModel` to the selected model id
|
|
144
|
+
- current thinking, telemetry, startup, and compaction defaults as today
|
|
145
|
+
|
|
146
|
+
Each catalog model should become one Pi model entry with:
|
|
147
|
+
|
|
148
|
+
- `id`
|
|
149
|
+
- `name`
|
|
150
|
+
- `reasoning`
|
|
151
|
+
- `input`
|
|
152
|
+
- `contextWindow` when known
|
|
153
|
+
- `maxTokens`
|
|
154
|
+
- zero local cost
|
|
155
|
+
|
|
156
|
+
This is what lets Pi `/model` switch among all launch-time catalog models without a localpi extension.
|
|
157
|
+
|
|
158
|
+
## Runtime Start Rules
|
|
159
|
+
|
|
160
|
+
Loaded models:
|
|
161
|
+
|
|
162
|
+
- If the selected model is already loaded behind an external OpenAI-compatible endpoint, just launch Pi against it.
|
|
163
|
+
|
|
164
|
+
Startable managed models:
|
|
165
|
+
|
|
166
|
+
- If the selected model is a managed `llama-server` alias, start or reuse localpi-owned `llama-server`.
|
|
167
|
+
- Preserve the existing rule that localpi should not silently start another heavyweight local runtime when LM Studio already has loaded models.
|
|
168
|
+
|
|
169
|
+
External providers:
|
|
170
|
+
|
|
171
|
+
- Never start or stop LM Studio, vLLM, TGI, Ollama, or other externally managed providers unless a future adapter explicitly owns that lifecycle.
|
|
172
|
+
|
|
173
|
+
## Implementation Phases
|
|
174
|
+
|
|
175
|
+
### Phase 1: Catalog Types And Discovery
|
|
176
|
+
|
|
177
|
+
- Add catalog types.
|
|
178
|
+
- Add provider registry loading.
|
|
179
|
+
- Add OpenAI-compatible provider adapter.
|
|
180
|
+
- Add managed `llama-server` provider adapter.
|
|
181
|
+
- Unit-test discovery for loaded, unavailable, and startable models.
|
|
182
|
+
|
|
183
|
+
### Phase 2: Startup Selection
|
|
184
|
+
|
|
185
|
+
- Replace runtime-first resolution with catalog-first resolution.
|
|
186
|
+
- Add terminal selector for interactive runs.
|
|
187
|
+
- Keep deterministic non-interactive behavior.
|
|
188
|
+
- Preserve explicit `--runtime`, `--provider`, and `--model` compatibility.
|
|
189
|
+
|
|
190
|
+
### Phase 3: Pi Config From Catalog
|
|
191
|
+
|
|
192
|
+
- Generate `models.json` from the full catalog.
|
|
193
|
+
- Generate `settings.json` from the selected catalog entry.
|
|
194
|
+
- Update launch planning to pass the selected provider/model.
|
|
195
|
+
- Add tests proving Pi config contains multiple providers and models.
|
|
196
|
+
|
|
197
|
+
### Phase 4: Runtime Lifecycle Integration
|
|
198
|
+
|
|
199
|
+
- Start managed `llama-server` only when the selected catalog entry is startable.
|
|
200
|
+
- Keep existing localpi-owned metadata and stop/reuse behavior.
|
|
201
|
+
- Keep LM Studio/vLLM as externally managed.
|
|
202
|
+
- Add tests for memory-safety prompts and failure messages.
|
|
203
|
+
|
|
204
|
+
### Phase 5: Documentation And Migration
|
|
205
|
+
|
|
206
|
+
- Document plain `localpi` selection behavior.
|
|
207
|
+
- Document provider registry config.
|
|
208
|
+
- Document non-interactive selection rules.
|
|
209
|
+
- Document that Pi `/model` sees the launch-time catalog.
|
|
210
|
+
- Document that live model refresh is not part of the first implementation.
|
|
211
|
+
|
|
212
|
+
## Out Of Scope
|
|
213
|
+
|
|
214
|
+
- Live refresh of Pi `/model` after Pi has started.
|
|
215
|
+
- Starting or stopping LM Studio.
|
|
216
|
+
- Guessing vLLM ports.
|
|
217
|
+
- Global system model management.
|
|
218
|
+
- Cloud provider authentication.
|
|
219
|
+
- Classifier-specific model routing.
|
|
220
|
+
- Localpager-specific behavior.
|