localpi 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -4
- package/dist/src/cli/cli.js +248 -14
- package/dist/src/localpi/catalog.js +79 -18
- package/dist/src/localpi/managed-runtime.js +2 -2
- package/dist/src/localpi/model-profile.js +98 -0
- package/dist/src/localpi/options.js +102 -4
- package/dist/src/localpi/runtime-connection.js +2 -4
- package/dist/src/localpi/settings-state.js +37 -0
- package/dist/src/pi/app.js +108 -0
- package/dist/src/pi/demo.js +12 -0
- package/dist/src/pi/extension-sources/demo-mode.js +52 -0
- package/dist/src/pi/extension-sources/startup-model-selector.js +78 -0
- package/dist/src/pi/extension-sources/thinking-control.js +79 -0
- package/dist/src/pi/extension-sources/token-status.js +133 -0
- package/dist/src/pi/extension-sources/tool-approval.js +45 -0
- package/dist/src/pi/extensions.js +11 -313
- package/dist/src/pi/version.js +25 -0
- package/docs/2026-06-16-startup-model-and-thinking-control-plan.md +6 -4
- package/docs/2026-06-18-endless-demo-mode-plan.md +192 -0
- package/docs/runtime-specification.md +26 -0
- package/package.json +4 -1
- package/dist/src/pi/config.js +0 -108
- package/dist/src/pi/launch.js +0 -64
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ Target default:
|
|
|
38
38
|
localpi --model gemma-12b
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
This uses the default `auto` runtime. If exactly one model is loaded locally, Localpi selects it. If multiple models are loaded in an interactive terminal, Localpi boots Pi with a temporary default and opens Pi's native model selector. If no external model is loaded and `llama-server` is installed, Localpi can fall back to the managed `llama-server` default. Thinking starts
|
|
41
|
+
This uses the default `auto` runtime. If exactly one model is loaded locally, Localpi selects it. If multiple models are loaded in an interactive terminal, Localpi boots Pi with a temporary default and opens Pi's native model selector. If no external model is loaded and `llama-server` is installed, Localpi can fall back to the managed `llama-server` default. Thinking starts from `--thinking`, `LOCALPI_THINKING`, the last saved Pi thinking level, or `medium`.
|
|
42
42
|
|
|
43
43
|
LM Studio is explicit:
|
|
44
44
|
|
|
@@ -109,6 +109,20 @@ Run a non-interactive Pi prompt:
|
|
|
109
109
|
localpi -p "summarize this repo"
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
+
Run an endless TUI demo:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
localpi --demo --model gemma-e4b
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Demo mode requires an explicit model, opens the normal Pi TUI, and keeps one live Pi session so followup prompts continue from the first prompt while Pi owns streaming, tok/s status, slash commands, and exit behavior.
|
|
119
|
+
|
|
120
|
+
Override the demo prompts:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
localpi --demo --model gemma-e4b --demo-initial-prompt-file ./prompts/story.txt --demo-followup-prompt "Continue. Try to write as long as possible."
|
|
124
|
+
```
|
|
125
|
+
|
|
112
126
|
Pin a model alias:
|
|
113
127
|
|
|
114
128
|
```bash
|
|
@@ -121,7 +135,7 @@ Use a bounded reasoning budget with managed `llama-server`:
|
|
|
121
135
|
localpi --model gemma-12b --thinking low -p "classify this item"
|
|
122
136
|
```
|
|
123
137
|
|
|
124
|
-
In an interactive session, use `/thinking` to pick a level or `/thinking high` to set one directly. This changes Pi's active thinking level for later turns. For managed `llama-server`, the server-side reasoning budget is still chosen at startup because changing it requires restarting the local server process.
|
|
138
|
+
In an interactive session, use `/thinking` to pick a level or `/thinking high` to set one directly. This changes Pi's active thinking level for later turns and saves it for the next localpi launch. For managed `llama-server`, the server-side reasoning budget is still chosen at startup because changing it requires restarting the local server process.
|
|
125
139
|
|
|
126
140
|
For managed `llama-server`, thinking levels map to server-side reasoning:
|
|
127
141
|
|
|
@@ -134,7 +148,7 @@ For managed `llama-server`, thinking levels map to server-side reasoning:
|
|
|
134
148
|
| `high` | `--reasoning on --reasoning-budget 2048` |
|
|
135
149
|
| `xhigh` | `--reasoning on --reasoning-budget 8192` |
|
|
136
150
|
|
|
137
|
-
The default is `
|
|
151
|
+
The fallback default is `medium`.
|
|
138
152
|
|
|
139
153
|
Point at vLLM:
|
|
140
154
|
|
|
@@ -179,8 +193,16 @@ localpi --stop
|
|
|
179
193
|
- `--session-dir <path>`: Pi session directory. Default: `<state-dir>/sessions`
|
|
180
194
|
- `--pi-command <command>`: Pi launch command
|
|
181
195
|
- `--providers-file <path>`: provider registry JSON
|
|
196
|
+
- `--model-profile <path>`: local model capability profile JSON
|
|
197
|
+
- `--model-reasoning <bool>`: override generated Pi reasoning capability
|
|
198
|
+
- `--model-thinking-format <deepseek|qwen-chat-template>`: override generated Pi thinking format
|
|
182
199
|
- `--tools <list>`: Pi tools allow list. Default: `read,bash,edit,write,grep,find,ls`
|
|
183
|
-
- `--thinking <off|minimal|low|medium|high|xhigh>`: Pi thinking level and managed `llama-server` reasoning budget. Default: `
|
|
200
|
+
- `--thinking <off|minimal|low|medium|high|xhigh>`: Pi thinking level and managed `llama-server` reasoning budget. Default: last saved level, then `medium`
|
|
201
|
+
- `--demo`: endlessly run Pi prompts inside the normal Pi TUI until interrupted or Pi exits; requires an explicit non-`auto` model
|
|
202
|
+
- `--demo-initial-prompt <text>`: first demo prompt
|
|
203
|
+
- `--demo-followup-prompt <text>`: repeated demo prompt after the first run
|
|
204
|
+
- `--demo-initial-prompt-file <path>`: UTF-8 file for the first demo prompt
|
|
205
|
+
- `--demo-followup-prompt-file <path>`: UTF-8 file for repeated demo prompts
|
|
184
206
|
- `--no-approval`: disable the tool approval gate
|
|
185
207
|
- `--no-token-status`: disable the token status extension
|
|
186
208
|
- `--status`: print runtime, model, and Pi config status
|
|
@@ -194,6 +216,9 @@ localpi --stop
|
|
|
194
216
|
- `LOCALPI_PROVIDER`
|
|
195
217
|
- `LOCALPI_BASE_URL`
|
|
196
218
|
- `LOCALPI_PROVIDERS_FILE`
|
|
219
|
+
- `LOCALPI_MODEL_PROFILE`
|
|
220
|
+
- `LOCALPI_MODEL_REASONING`
|
|
221
|
+
- `LOCALPI_MODEL_THINKING_FORMAT`
|
|
197
222
|
- `LOCALPI_STATE_DIR`
|
|
198
223
|
- `LOCALPI_SESSION_DIR`
|
|
199
224
|
- `LOCALPI_PI_CMD`
|
|
@@ -207,7 +232,15 @@ localpi --stop
|
|
|
207
232
|
- `LOCALPI_CHAT_TEMPLATE`
|
|
208
233
|
- `LOCALPI_TOOLS`
|
|
209
234
|
- `LOCALPI_THINKING`
|
|
235
|
+
- `LOCALPI_DEMO`
|
|
236
|
+
- `LOCALPI_DEMO_INITIAL_PROMPT`
|
|
237
|
+
- `LOCALPI_DEMO_FOLLOWUP_PROMPT`
|
|
238
|
+
- `LOCALPI_DEMO_INITIAL_PROMPT_FILE`
|
|
239
|
+
- `LOCALPI_DEMO_FOLLOWUP_PROMPT_FILE`
|
|
210
240
|
- `LOCALPI_MODELS_FILE`
|
|
241
|
+
- `LOCALPAGER_AGENT_PROFILE`
|
|
242
|
+
- `LOCALPAGER_AGENT_REASONING`
|
|
243
|
+
- `LOCALPAGER_AGENT_THINKING_FORMAT`
|
|
211
244
|
|
|
212
245
|
`LOCALPI_MODELS_FILE` may point at a JSON file with this shape:
|
|
213
246
|
|
|
@@ -241,6 +274,26 @@ Provider registries use the same file or `LOCALPI_PROVIDERS_FILE`:
|
|
|
241
274
|
|
|
242
275
|
Use `discover: false` for endpoints that should not be probed during startup. They can still be selected explicitly with `--provider vllm-qwen --model <id>`.
|
|
243
276
|
|
|
277
|
+
Model capability profiles can fill in metadata that OpenAI-compatible servers do not expose through `/v1/models`, such as vLLM reasoning support:
|
|
278
|
+
|
|
279
|
+
```json
|
|
280
|
+
{
|
|
281
|
+
"id": "gemma4-26b-a4b-nvfp4",
|
|
282
|
+
"model": "nvidia/Gemma-4-26B-A4B-NVFP4",
|
|
283
|
+
"base_url": "http://127.0.0.1:8000/v1",
|
|
284
|
+
"client": {
|
|
285
|
+
"context_window": 32768,
|
|
286
|
+
"max_tokens": 4096
|
|
287
|
+
},
|
|
288
|
+
"capabilities": {
|
|
289
|
+
"reasoning": true,
|
|
290
|
+
"thinking_format": "qwen-chat-template"
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`LOCALPAGER_AGENT_PROFILE`, `LOCALPAGER_AGENT_REASONING`, and `LOCALPAGER_AGENT_THINKING_FORMAT` are accepted as aliases so LocalPager Agent can pass the same profile metadata through to localpi.
|
|
296
|
+
|
|
244
297
|
## Development
|
|
245
298
|
|
|
246
299
|
```bash
|
package/dist/src/cli/cli.js
CHANGED
|
@@ -1,35 +1,250 @@
|
|
|
1
|
+
import { runPiApp } from "@dutifuldev/pi-factory";
|
|
1
2
|
import { errorMessage, fail, ok } from "../common/result.js";
|
|
2
3
|
import { parseLocalpiArgs, usage } from "../localpi/options.js";
|
|
3
4
|
import { aliasListOutput, connectionStatus, resolveRuntime, statusOutput, stopRuntime } from "../localpi/runtime.js";
|
|
4
|
-
import {
|
|
5
|
+
import { applyRememberedSettings } from "../localpi/settings-state.js";
|
|
6
|
+
import { createLocalpiAppDefinition } from "../pi/app.js";
|
|
5
7
|
import { writeDefaultExtensions } from "../pi/extensions.js";
|
|
6
|
-
import { createLaunchPlan, execLaunchPlan } from "../pi/launch.js";
|
|
7
8
|
export async function run(args) {
|
|
8
9
|
try {
|
|
9
|
-
|
|
10
|
+
let options = parseLocalpiArgs(args);
|
|
11
|
+
const helpResult = helpCommandResult(options);
|
|
12
|
+
if (helpResult !== undefined) {
|
|
13
|
+
return helpResult;
|
|
14
|
+
}
|
|
15
|
+
validateDemoOptions(options);
|
|
10
16
|
const commandResult = await immediateCommandResult(options);
|
|
11
17
|
if (commandResult !== undefined) {
|
|
12
18
|
return commandResult;
|
|
13
19
|
}
|
|
20
|
+
options = await applyRememberedSettings(options, {
|
|
21
|
+
thinking: hasExplicitThinkingOverride(args)
|
|
22
|
+
});
|
|
14
23
|
const connection = await resolveRuntime(options);
|
|
15
|
-
const runtimeConfig = await writeRuntimeConfig(options, connection);
|
|
16
24
|
const selectorOptions = startupModelSelectorOptions(options, connection);
|
|
17
25
|
const extensions = await writeDefaultExtensions(options, selectorOptions === undefined ? {} : { startupModelSelector: selectorOptions });
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
if (code !== 0) {
|
|
21
|
-
return { code, stdout: "", stderr: "" };
|
|
22
|
-
}
|
|
23
|
-
return ok(connection.warnings.length === 0 ? "" : connectionStatus(connection));
|
|
26
|
+
const app = createLocalpiAppDefinition(options, connection, extensions);
|
|
27
|
+
return await launchResolvedRuntime(app, connection);
|
|
24
28
|
}
|
|
25
29
|
catch (error) {
|
|
26
30
|
return fail(`localpi: ${errorMessage(error)}`);
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
|
-
|
|
30
|
-
if (options.
|
|
31
|
-
return
|
|
33
|
+
function validateDemoOptions(options) {
|
|
34
|
+
if (!options.demo) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
validateExplicitDemoImmediateOptions(options);
|
|
38
|
+
if (!options.demoFromCli && hasImmediateCommand(options)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
validateDemoModel(options);
|
|
42
|
+
validateDemoTty();
|
|
43
|
+
validateForwardedDemoOptions(options.forwardedArgs);
|
|
44
|
+
}
|
|
45
|
+
function validateForwardedDemoOptions(args) {
|
|
46
|
+
const incompatibleMode = forwardedIncompatibleMode(args);
|
|
47
|
+
if (incompatibleMode !== undefined) {
|
|
48
|
+
throw new Error(`--demo cannot be used with forwarded Pi mode ${incompatibleMode}; demo mode runs inside Pi TUI`);
|
|
49
|
+
}
|
|
50
|
+
const metadataFlag = forwardedMetadataFlag(args);
|
|
51
|
+
if (metadataFlag !== undefined) {
|
|
52
|
+
throw new Error(`--demo cannot be used with forwarded Pi metadata flag ${metadataFlag}; run it without --demo`);
|
|
53
|
+
}
|
|
54
|
+
const extensionDisableFlag = forwardedExtensionDisableFlag(args);
|
|
55
|
+
if (extensionDisableFlag !== undefined) {
|
|
56
|
+
throw new Error(`--demo cannot be used with forwarded Pi extension flag ${extensionDisableFlag}; demo mode requires localpi's generated Pi extension`);
|
|
57
|
+
}
|
|
58
|
+
const sessionFlag = forwardedSessionFlag(args);
|
|
59
|
+
if (sessionFlag !== undefined) {
|
|
60
|
+
throw new Error(`--demo cannot be used with forwarded Pi session flag ${sessionFlag}; demo mode manages its own session`);
|
|
61
|
+
}
|
|
62
|
+
const promptInput = forwardedPromptInput(args);
|
|
63
|
+
if (promptInput !== undefined) {
|
|
64
|
+
throw new Error(`--demo cannot be used with forwarded Pi prompt input ${promptInput}; use --demo-initial-prompt or --demo-followup-prompt`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function validateDemoModel(options) {
|
|
68
|
+
if (options.model === undefined || options.model === "auto") {
|
|
69
|
+
throw new Error("--demo requires an explicit --model <alias|id|path> or LOCALPI_MODEL value; demo mode will not auto-select a model");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function validateDemoTty() {
|
|
73
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
74
|
+
throw new Error("--demo requires an interactive TTY on stdin and stdout; run it directly in a terminal");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function forwardedIncompatibleMode(args) {
|
|
78
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
79
|
+
const arg = args[index];
|
|
80
|
+
if (arg === "--mode") {
|
|
81
|
+
return args[index + 1] ?? "--mode";
|
|
82
|
+
}
|
|
83
|
+
if (arg?.startsWith("--mode=")) {
|
|
84
|
+
return arg.slice("--mode=".length);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
function hasImmediateCommand(options) {
|
|
90
|
+
return options.status || options.stop || options.list;
|
|
91
|
+
}
|
|
92
|
+
function validateExplicitDemoImmediateOptions(options) {
|
|
93
|
+
if (!options.demoFromCli) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (options.status) {
|
|
97
|
+
throw new Error("--demo cannot be used with --status");
|
|
98
|
+
}
|
|
99
|
+
if (options.stop) {
|
|
100
|
+
throw new Error("--demo cannot be used with --stop");
|
|
32
101
|
}
|
|
102
|
+
if (options.list) {
|
|
103
|
+
throw new Error("--demo cannot be used with --list");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function forwardedPromptInput(args) {
|
|
107
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
108
|
+
const scan = scanForwardedPromptInput(args, index);
|
|
109
|
+
if (scan.promptInput !== undefined) {
|
|
110
|
+
return scan.promptInput;
|
|
111
|
+
}
|
|
112
|
+
index += scan.consumed - 1;
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
function scanForwardedPromptInput(args, index) {
|
|
117
|
+
const arg = args[index];
|
|
118
|
+
if (arg === undefined) {
|
|
119
|
+
return noForwardedPromptInput(1);
|
|
120
|
+
}
|
|
121
|
+
if (isForwardedPromptFlag(arg) || arg.startsWith("@")) {
|
|
122
|
+
return { consumed: 1, promptInput: arg };
|
|
123
|
+
}
|
|
124
|
+
if (isPiValueToken(arg, args[index + 1])) {
|
|
125
|
+
return noForwardedPromptInput(2);
|
|
126
|
+
}
|
|
127
|
+
if (isPiIgnoredToken(arg)) {
|
|
128
|
+
return noForwardedPromptInput(1);
|
|
129
|
+
}
|
|
130
|
+
return arg.startsWith("-") ? noForwardedPromptInput(1) : { consumed: 1, promptInput: arg };
|
|
131
|
+
}
|
|
132
|
+
function noForwardedPromptInput(consumed) {
|
|
133
|
+
return { consumed, promptInput: undefined };
|
|
134
|
+
}
|
|
135
|
+
function isForwardedPromptFlag(arg) {
|
|
136
|
+
return arg === "-p" || arg === "--print" || arg === "--prompt" || arg.startsWith("--prompt=");
|
|
137
|
+
}
|
|
138
|
+
function forwardedSessionFlag(args) {
|
|
139
|
+
return args.find((arg) => isForwardedSessionFlag(arg));
|
|
140
|
+
}
|
|
141
|
+
const forwardedSessionFlags = new Set([
|
|
142
|
+
"--continue",
|
|
143
|
+
"-c",
|
|
144
|
+
"--resume",
|
|
145
|
+
"-r",
|
|
146
|
+
"--session",
|
|
147
|
+
"--session-id",
|
|
148
|
+
"--fork",
|
|
149
|
+
"--no-session"
|
|
150
|
+
]);
|
|
151
|
+
const forwardedSessionEqualsFlags = [
|
|
152
|
+
"--continue",
|
|
153
|
+
"--resume",
|
|
154
|
+
"--session",
|
|
155
|
+
"--session-id",
|
|
156
|
+
"--fork",
|
|
157
|
+
"--no-session"
|
|
158
|
+
];
|
|
159
|
+
function isForwardedSessionFlag(arg) {
|
|
160
|
+
return (forwardedSessionFlags.has(arg) ||
|
|
161
|
+
forwardedSessionEqualsFlags.some((flag) => arg.startsWith(`${flag}=`)));
|
|
162
|
+
}
|
|
163
|
+
function forwardedMetadataFlag(args) {
|
|
164
|
+
return args.find((arg) => isPiMetadataFlag(arg));
|
|
165
|
+
}
|
|
166
|
+
function forwardedExtensionDisableFlag(args) {
|
|
167
|
+
return args.find((arg) => arg === "--no-extensions" || arg === "-ne");
|
|
168
|
+
}
|
|
169
|
+
function isPiMetadataFlag(arg) {
|
|
170
|
+
return (arg === "--help" ||
|
|
171
|
+
arg === "-h" ||
|
|
172
|
+
arg === "--version" ||
|
|
173
|
+
arg === "-v" ||
|
|
174
|
+
arg === "--list-models" ||
|
|
175
|
+
arg === "--export");
|
|
176
|
+
}
|
|
177
|
+
function isPiValueToken(arg, next) {
|
|
178
|
+
return isPiValueFlag(arg) || isPiUnknownLongFlagValue(arg, next);
|
|
179
|
+
}
|
|
180
|
+
function isPiIgnoredToken(arg) {
|
|
181
|
+
return isPiBooleanFlag(arg) || isPiMetadataFlag(arg) || isForwardedSessionFlag(arg);
|
|
182
|
+
}
|
|
183
|
+
function isPiValueFlag(arg) {
|
|
184
|
+
return [
|
|
185
|
+
"--mode",
|
|
186
|
+
"--provider",
|
|
187
|
+
"--model",
|
|
188
|
+
"--api-key",
|
|
189
|
+
"--system-prompt",
|
|
190
|
+
"--append-system-prompt",
|
|
191
|
+
"--name",
|
|
192
|
+
"-n",
|
|
193
|
+
"--session",
|
|
194
|
+
"--session-id",
|
|
195
|
+
"--fork",
|
|
196
|
+
"--session-dir",
|
|
197
|
+
"--models",
|
|
198
|
+
"--tools",
|
|
199
|
+
"-t",
|
|
200
|
+
"--exclude-tools",
|
|
201
|
+
"-xt",
|
|
202
|
+
"--thinking",
|
|
203
|
+
"--export",
|
|
204
|
+
"--extension",
|
|
205
|
+
"-e",
|
|
206
|
+
"--skill",
|
|
207
|
+
"--prompt-template",
|
|
208
|
+
"--theme"
|
|
209
|
+
].includes(arg);
|
|
210
|
+
}
|
|
211
|
+
function isPiBooleanFlag(arg) {
|
|
212
|
+
return [
|
|
213
|
+
"--no-tools",
|
|
214
|
+
"-nt",
|
|
215
|
+
"--no-builtin-tools",
|
|
216
|
+
"-nbt",
|
|
217
|
+
"--no-extensions",
|
|
218
|
+
"-ne",
|
|
219
|
+
"--no-skills",
|
|
220
|
+
"-ns",
|
|
221
|
+
"--no-prompt-templates",
|
|
222
|
+
"-np",
|
|
223
|
+
"--no-themes",
|
|
224
|
+
"--no-context-files",
|
|
225
|
+
"-nc",
|
|
226
|
+
"--verbose",
|
|
227
|
+
"--approve",
|
|
228
|
+
"-a",
|
|
229
|
+
"--no-approve",
|
|
230
|
+
"-na",
|
|
231
|
+
"--offline"
|
|
232
|
+
].includes(arg);
|
|
233
|
+
}
|
|
234
|
+
function isPiUnknownLongFlagValue(arg, next) {
|
|
235
|
+
return arg.startsWith("--") && !arg.includes("=") && isPiUnknownLongFlagNextValue(next);
|
|
236
|
+
}
|
|
237
|
+
function isPiUnknownLongFlagNextValue(arg) {
|
|
238
|
+
return arg !== undefined && !arg.startsWith("-") && !arg.startsWith("@");
|
|
239
|
+
}
|
|
240
|
+
async function launchResolvedRuntime(app, connection) {
|
|
241
|
+
const code = await runPiApp(app);
|
|
242
|
+
if (code !== 0) {
|
|
243
|
+
return { code, stdout: "", stderr: "" };
|
|
244
|
+
}
|
|
245
|
+
return ok(connection.warnings.length === 0 ? "" : connectionStatus(connection));
|
|
246
|
+
}
|
|
247
|
+
async function immediateCommandResult(options) {
|
|
33
248
|
if (options.list) {
|
|
34
249
|
return ok(`${await aliasListOutput()}\n`);
|
|
35
250
|
}
|
|
@@ -38,8 +253,27 @@ async function immediateCommandResult(options) {
|
|
|
38
253
|
}
|
|
39
254
|
return options.status ? ok(`${await statusOutput(options)}\n`) : undefined;
|
|
40
255
|
}
|
|
256
|
+
function helpCommandResult(options) {
|
|
257
|
+
return options.forwardedArgs.length === 1 && options.forwardedArgs[0] === "--help"
|
|
258
|
+
? ok(usage())
|
|
259
|
+
: undefined;
|
|
260
|
+
}
|
|
261
|
+
function hasExplicitThinkingOverride(args) {
|
|
262
|
+
if (process.env["LOCALPI_THINKING"] !== undefined) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
for (const arg of args) {
|
|
266
|
+
if (arg === "--") {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
if (arg === "--thinking") {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
41
275
|
function startupModelSelectorOptions(options, connection) {
|
|
42
|
-
if (!process.stdin.isTTY || !process.
|
|
276
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
43
277
|
return undefined;
|
|
44
278
|
}
|
|
45
279
|
if (options.model !== undefined && options.model !== "auto") {
|
|
@@ -1,37 +1,39 @@
|
|
|
1
1
|
import { listModels } from "../llm/openai.js";
|
|
2
2
|
import { getManagedLlamaServerMetadata, getLlamaServerModels, llamaBaseUrl, managedLlamaServerUnavailableMessage } from "./llama-server.js";
|
|
3
3
|
import { listModelAliases, resolveLlamaModel } from "./models.js";
|
|
4
|
+
import { loadLocalModelProfile, profileMatchesBaseUrl, profileMatchesModel } from "./model-profile.js";
|
|
4
5
|
import { providerConfigs } from "./provider-registry.js";
|
|
5
6
|
export async function discoverModelCatalog(options) {
|
|
6
7
|
const configs = await providerConfigs(options);
|
|
7
|
-
const
|
|
8
|
+
const profile = await loadLocalModelProfile(options);
|
|
9
|
+
const discovered = await Promise.all(configs.map((config) => discoverProvider(config, options, profile)));
|
|
8
10
|
return {
|
|
9
11
|
models: discovered.flatMap((entry) => entry.models),
|
|
10
12
|
warnings: discovered.flatMap((entry) => entry.warnings)
|
|
11
13
|
};
|
|
12
14
|
}
|
|
13
|
-
async function discoverProvider(config, options) {
|
|
15
|
+
async function discoverProvider(config, options, profile) {
|
|
14
16
|
switch (config.type) {
|
|
15
17
|
case "openai-compatible":
|
|
16
|
-
return discoverOpenAiCompatibleProvider(config, options);
|
|
18
|
+
return discoverOpenAiCompatibleProvider(config, options, profile);
|
|
17
19
|
case "managed-llama-server":
|
|
18
20
|
return discoverManagedLlamaProvider(config, options);
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
|
-
async function discoverOpenAiCompatibleProvider(config, options) {
|
|
23
|
+
async function discoverOpenAiCompatibleProvider(config, options, profile) {
|
|
22
24
|
if (config.baseUrl === undefined) {
|
|
23
25
|
return { models: [], warnings: [] };
|
|
24
26
|
}
|
|
25
27
|
if (!config.discover) {
|
|
26
|
-
const explicitModel = explicitOpenAiCatalogModel(config, [], options);
|
|
28
|
+
const explicitModel = explicitOpenAiCatalogModel(config, [], options, profile);
|
|
27
29
|
return { models: explicitModel === undefined ? [] : [explicitModel], warnings: [] };
|
|
28
30
|
}
|
|
29
31
|
try {
|
|
30
32
|
const models = await listModels(config.baseUrl, options.timeoutMs);
|
|
31
|
-
const explicitModel = explicitOpenAiCatalogModel(config, models, options);
|
|
33
|
+
const explicitModel = explicitOpenAiCatalogModel(config, models, options, profile);
|
|
32
34
|
return {
|
|
33
35
|
models: explicitModel === undefined
|
|
34
|
-
? models.map((model) => openAiCatalogModel(config, model, options))
|
|
36
|
+
? models.map((model) => openAiCatalogModel(config, model, options, profile))
|
|
35
37
|
: [explicitModel],
|
|
36
38
|
warnings: []
|
|
37
39
|
};
|
|
@@ -50,24 +52,35 @@ async function discoverOpenAiCompatibleProvider(config, options) {
|
|
|
50
52
|
};
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
|
-
function openAiCatalogModel(config, model, options) {
|
|
55
|
+
function openAiCatalogModel(config, model, options, profile) {
|
|
54
56
|
const baseUrl = config.baseUrl ?? "";
|
|
57
|
+
const profileConfig = profileCapabilityConfig(profile, baseUrl, model.id);
|
|
58
|
+
const aliases = profileAliases(profile, baseUrl, model.id);
|
|
59
|
+
const contextWindow = profileConfig.contextWindow ?? model.contextWindow;
|
|
55
60
|
return {
|
|
56
61
|
providerId: config.id,
|
|
57
62
|
providerName: config.name,
|
|
58
63
|
runtime: "openai-compatible",
|
|
59
64
|
baseUrl,
|
|
60
65
|
modelId: model.id,
|
|
61
|
-
aliases
|
|
66
|
+
aliases,
|
|
62
67
|
displayName: `${config.name} / ${model.id}`,
|
|
63
|
-
maxTokens: options.maxTokens,
|
|
64
|
-
...
|
|
68
|
+
maxTokens: profileConfig.maxTokens ?? options.maxTokens,
|
|
69
|
+
...externalCapabilityConfig(config.id, model.id, profileConfig, options),
|
|
65
70
|
capabilities: ["text"],
|
|
66
71
|
availability: "loaded",
|
|
67
|
-
...(
|
|
72
|
+
...(contextWindow === undefined ? {} : { contextWindow })
|
|
68
73
|
};
|
|
69
74
|
}
|
|
70
|
-
function
|
|
75
|
+
function profileAliases(profile, baseUrl, modelId) {
|
|
76
|
+
if (profile === undefined ||
|
|
77
|
+
!profileMatchesBaseUrl(profile, baseUrl) ||
|
|
78
|
+
!profileMatchesModel(profile, modelId)) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
return [profile.id, profile.model].filter((alias, index, aliases) => alias !== modelId && aliases.indexOf(alias) === index);
|
|
82
|
+
}
|
|
83
|
+
function explicitOpenAiCatalogModel(config, models, options, profile) {
|
|
71
84
|
const requested = options.model;
|
|
72
85
|
if (models.length !== 0 || requested === undefined || requested === "auto") {
|
|
73
86
|
return undefined;
|
|
@@ -75,7 +88,15 @@ function explicitOpenAiCatalogModel(config, models, options) {
|
|
|
75
88
|
if (!explicitOpenAiProviderSelected(options, config.id)) {
|
|
76
89
|
return undefined;
|
|
77
90
|
}
|
|
78
|
-
return openAiCatalogModel(config, { id: requested }, options);
|
|
91
|
+
return openAiCatalogModel(config, { id: explicitProfileModelId(profile, config.baseUrl ?? "", requested) }, options, profile);
|
|
92
|
+
}
|
|
93
|
+
function explicitProfileModelId(profile, baseUrl, requested) {
|
|
94
|
+
if (profile !== undefined &&
|
|
95
|
+
profileMatchesBaseUrl(profile, baseUrl) &&
|
|
96
|
+
profileMatchesModel(profile, requested)) {
|
|
97
|
+
return profile.model;
|
|
98
|
+
}
|
|
99
|
+
return requested;
|
|
79
100
|
}
|
|
80
101
|
function explicitOpenAiProviderSelected(options, providerId) {
|
|
81
102
|
return (options.provider === providerId ||
|
|
@@ -120,7 +141,7 @@ async function loadedLlamaModels(config, options, baseUrl, aliases) {
|
|
|
120
141
|
aliases: aliases.filter((alias) => alias.id === model.id).map((alias) => alias.name),
|
|
121
142
|
displayName: `${config.name} / ${model.id}`,
|
|
122
143
|
maxTokens: options.maxTokens,
|
|
123
|
-
|
|
144
|
+
...managedCapabilityConfig(model.id, options),
|
|
124
145
|
capabilities: ["text"],
|
|
125
146
|
availability: "loaded",
|
|
126
147
|
...(contextWindow === undefined ? {} : { contextWindow })
|
|
@@ -146,7 +167,7 @@ async function startableLlamaModels(config, options, baseUrl, loaded, aliases) {
|
|
|
146
167
|
aliases: [alias.name],
|
|
147
168
|
displayName: `${config.name} / ${alias.name}`,
|
|
148
169
|
maxTokens: options.maxTokens,
|
|
149
|
-
|
|
170
|
+
...managedCapabilityConfig(resolved.id, options),
|
|
150
171
|
capabilities: ["text"],
|
|
151
172
|
availability: "startable",
|
|
152
173
|
...(resolved.contextWindow === undefined ? {} : { contextWindow: resolved.contextWindow })
|
|
@@ -158,7 +179,19 @@ async function startableLlamaModels(config, options, baseUrl, loaded, aliases) {
|
|
|
158
179
|
}));
|
|
159
180
|
return startable.filter((model) => model !== undefined);
|
|
160
181
|
}
|
|
161
|
-
function
|
|
182
|
+
function externalCapabilityConfig(providerId, modelId, profileConfig, options) {
|
|
183
|
+
const baseConfig = profileConfig.reasoning !== undefined || profileConfig.thinkingFormat !== undefined
|
|
184
|
+
? {
|
|
185
|
+
reasoning: profileConfig.reasoning,
|
|
186
|
+
thinkingFormat: profileConfig.thinkingFormat
|
|
187
|
+
}
|
|
188
|
+
: externalReasoningConfig(providerId, modelId);
|
|
189
|
+
return withoutUndefined({
|
|
190
|
+
reasoning: options.modelReasoning ?? baseConfig.reasoning,
|
|
191
|
+
thinkingFormat: options.modelThinkingFormat ?? baseConfig.thinkingFormat
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function externalReasoningConfig(providerId, modelId) {
|
|
162
195
|
const normalized = modelId.toLowerCase();
|
|
163
196
|
if (isDeepSeekThinkingModel(normalized)) {
|
|
164
197
|
return { reasoning: true, thinkingFormat: "deepseek" };
|
|
@@ -166,6 +199,9 @@ function externalReasoningConfig(modelId) {
|
|
|
166
199
|
if (isQwenThinkingModel(normalized)) {
|
|
167
200
|
return { reasoning: true, thinkingFormat: "qwen-chat-template" };
|
|
168
201
|
}
|
|
202
|
+
if (providerId === "vllm" && isGemmaThinkingModel(normalized)) {
|
|
203
|
+
return { reasoning: true, thinkingFormat: "qwen-chat-template" };
|
|
204
|
+
}
|
|
169
205
|
return {};
|
|
170
206
|
}
|
|
171
207
|
export function managedModelSupportsReasoning(modelId) {
|
|
@@ -175,7 +211,13 @@ export function managedModelSupportsReasoning(modelId) {
|
|
|
175
211
|
isDeepSeekThinkingModel(normalized) ||
|
|
176
212
|
isQwenThinkingModel(normalized) ||
|
|
177
213
|
normalized.includes("gpt-oss") ||
|
|
178
|
-
normalized
|
|
214
|
+
isGemmaThinkingModel(normalized));
|
|
215
|
+
}
|
|
216
|
+
export function managedCapabilityConfig(modelId, options) {
|
|
217
|
+
return withoutUndefined({
|
|
218
|
+
reasoning: options.modelReasoning ?? managedModelSupportsReasoning(modelId),
|
|
219
|
+
thinkingFormat: options.modelThinkingFormat
|
|
220
|
+
});
|
|
179
221
|
}
|
|
180
222
|
export function runtimeCatalogWarning(providerId, providerName, message) {
|
|
181
223
|
return catalogWarning(providerId, providerName, "runtime-warning", { message });
|
|
@@ -221,6 +263,25 @@ function isQwenThinkingModel(normalizedModelId) {
|
|
|
221
263
|
(normalizedModelId.includes("qwen") &&
|
|
222
264
|
(normalizedModelId.includes("reason") || normalizedModelId.includes("thinking"))));
|
|
223
265
|
}
|
|
266
|
+
function profileCapabilityConfig(profile, baseUrl, modelId) {
|
|
267
|
+
if (profile === undefined ||
|
|
268
|
+
!profileMatchesBaseUrl(profile, baseUrl) ||
|
|
269
|
+
!profileMatchesModel(profile, modelId)) {
|
|
270
|
+
return {};
|
|
271
|
+
}
|
|
272
|
+
return withoutUndefined({
|
|
273
|
+
reasoning: profile.capabilities?.reasoning,
|
|
274
|
+
thinkingFormat: profile.capabilities?.thinkingFormat,
|
|
275
|
+
contextWindow: profile.client?.contextWindow,
|
|
276
|
+
maxTokens: profile.client?.maxTokens
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function isGemmaThinkingModel(normalizedModelId) {
|
|
280
|
+
return normalizedModelId.includes("gemma-4") || normalizedModelId.includes("gemma4");
|
|
281
|
+
}
|
|
224
282
|
function hasModelToken(normalizedModelId, token) {
|
|
225
283
|
return normalizedModelId.split(/[^a-z0-9]+/u).includes(token);
|
|
226
284
|
}
|
|
285
|
+
function withoutUndefined(value) {
|
|
286
|
+
return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined));
|
|
287
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ensureLlamaServer, getManagedLlamaServerMetadata, getLlamaServerModels, llamaBaseUrl, managedLlamaServerNeedsRestart, stopManagedLlamaServer } from "./llama-server.js";
|
|
2
|
-
import {
|
|
2
|
+
import { managedCapabilityConfig, runtimeCatalogWarning } from "./catalog.js";
|
|
3
3
|
import { catalogModelFromModelInfo, catalogRuntimeConnection, connectionCatalogModels, modelChoiceList, optionalContextWindow, replaceManagedLoadedModels } from "./runtime-connection.js";
|
|
4
4
|
import { defaultLlamaModelName, findModelAlias, resolveLlamaModel } from "./models.js";
|
|
5
5
|
export async function resolveLlamaRuntime(options) {
|
|
@@ -56,7 +56,7 @@ export async function customPathCatalogModel(options, provider, requested) {
|
|
|
56
56
|
aliases: [requested],
|
|
57
57
|
displayName: `llama-server / ${resolved.name}`,
|
|
58
58
|
maxTokens: options.maxTokens,
|
|
59
|
-
|
|
59
|
+
...managedCapabilityConfig(resolved.id, options),
|
|
60
60
|
capabilities: ["text"],
|
|
61
61
|
availability: "startable",
|
|
62
62
|
...optionalContextWindow(options.contextWindow ?? resolved.contextWindow)
|