localpi 0.1.1 → 0.3.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 CHANGED
@@ -4,7 +4,7 @@ Localpi is a local Pi launcher for open-weight models.
4
4
 
5
5
  By default, Localpi discovers available local providers, lets you choose when more than one model is loaded, points Pi at the selected model, and writes Pi config for the other discovered models so `/model` can switch among them during the session.
6
6
 
7
- Localpi supports LM Studio, vLLM, custom OpenAI-compatible servers, and a managed `llama-server` fallback.
7
+ Localpi supports LM Studio, vLLM, custom OpenAI-compatible servers, and an optional managed `llama-server` fallback.
8
8
 
9
9
  Localpi is intentionally generic. It does not contain classifier prompts, dataset workflows, GitHub routing logic, or final-schema output machinery. Structured classifier runs belong in caller tools such as `localpager-agent`.
10
10
 
@@ -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, Localpi can fall back to the managed `llama-server` default. Thinking starts as `off` unless `--thinking` or `LOCALPI_THINKING` sets another startup level.
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 `off`.
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: `off`
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
@@ -1,35 +1,250 @@
1
1
  import { errorMessage, fail, ok } from "../common/result.js";
2
2
  import { parseLocalpiArgs, usage } from "../localpi/options.js";
3
3
  import { aliasListOutput, connectionStatus, resolveRuntime, statusOutput, stopRuntime } from "../localpi/runtime.js";
4
+ import { applyRememberedSettings } from "../localpi/settings-state.js";
4
5
  import { writeRuntimeConfig } from "../pi/config.js";
5
6
  import { writeDefaultExtensions } from "../pi/extensions.js";
6
7
  import { createLaunchPlan, execLaunchPlan } from "../pi/launch.js";
7
8
  export async function run(args) {
8
9
  try {
9
- const options = parseLocalpiArgs(args);
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
24
  const runtimeConfig = await writeRuntimeConfig(options, connection);
16
25
  const selectorOptions = startupModelSelectorOptions(options, connection);
17
26
  const extensions = await writeDefaultExtensions(options, selectorOptions === undefined ? {} : { startupModelSelector: selectorOptions });
18
- const plan = await createLaunchPlan(options, runtimeConfig, connection, extensions);
19
- const code = await execLaunchPlan(plan);
20
- if (code !== 0) {
21
- return { code, stdout: "", stderr: "" };
22
- }
23
- return ok(connection.warnings.length === 0 ? "" : connectionStatus(connection));
27
+ return await launchResolvedRuntime(options, runtimeConfig, connection, extensions);
24
28
  }
25
29
  catch (error) {
26
30
  return fail(`localpi: ${errorMessage(error)}`);
27
31
  }
28
32
  }
29
- async function immediateCommandResult(options) {
30
- if (options.forwardedArgs.length === 1 && options.forwardedArgs[0] === "--help") {
31
- return ok(usage());
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(options, runtimeConfig, connection, extensions) {
241
+ const code = await execLaunchPlan(await createLaunchPlan(options, runtimeConfig, connection, extensions));
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.stderr.isTTY) {
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
- import { getManagedLlamaServerMetadata, getLlamaServerModels, llamaBaseUrl } from "./llama-server.js";
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 discovered = await Promise.all(configs.map((config) => discoverProvider(config, options)));
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
  };
@@ -40,27 +42,45 @@ async function discoverOpenAiCompatibleProvider(config, options) {
40
42
  if (explicitOpenAiProviderSelected(options, config.id)) {
41
43
  throw error;
42
44
  }
43
- return { models: [], warnings: [`${config.name} is not responding at ${config.baseUrl}`] };
45
+ return {
46
+ models: [],
47
+ warnings: [
48
+ catalogWarning(config.id, config.name, "provider-not-responding", {
49
+ message: `not responding at ${config.baseUrl}`
50
+ })
51
+ ]
52
+ };
44
53
  }
45
54
  }
46
- function openAiCatalogModel(config, model, options) {
55
+ function openAiCatalogModel(config, model, options, profile) {
47
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;
48
60
  return {
49
61
  providerId: config.id,
50
62
  providerName: config.name,
51
63
  runtime: "openai-compatible",
52
64
  baseUrl,
53
65
  modelId: model.id,
54
- aliases: [],
66
+ aliases,
55
67
  displayName: `${config.name} / ${model.id}`,
56
- maxTokens: options.maxTokens,
57
- ...externalReasoningConfig(model.id),
68
+ maxTokens: profileConfig.maxTokens ?? options.maxTokens,
69
+ ...externalCapabilityConfig(config.id, model.id, profileConfig, options),
58
70
  capabilities: ["text"],
59
71
  availability: "loaded",
60
- ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow })
72
+ ...(contextWindow === undefined ? {} : { contextWindow })
61
73
  };
62
74
  }
63
- function explicitOpenAiCatalogModel(config, models, options) {
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) {
64
84
  const requested = options.model;
65
85
  if (models.length !== 0 || requested === undefined || requested === "auto") {
66
86
  return undefined;
@@ -68,7 +88,15 @@ function explicitOpenAiCatalogModel(config, models, options) {
68
88
  if (!explicitOpenAiProviderSelected(options, config.id)) {
69
89
  return undefined;
70
90
  }
71
- 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;
72
100
  }
73
101
  function explicitOpenAiProviderSelected(options, providerId) {
74
102
  return (options.provider === providerId ||
@@ -81,10 +109,18 @@ async function discoverManagedLlamaProvider(config, options) {
81
109
  const baseUrl = llamaBaseUrl(options);
82
110
  const aliases = await listModelAliases();
83
111
  const loaded = await loadedLlamaModels(config, options, baseUrl, aliases);
112
+ const unavailableMessage = await managedLlamaServerUnavailableMessage(options);
84
113
  const startable = await startableLlamaModels(config, options, baseUrl, loaded.models, aliases);
85
114
  return {
86
115
  models: [...loaded.models, ...startable],
87
- warnings: loaded.warnings
116
+ warnings: unavailableMessage === undefined
117
+ ? loaded.warnings
118
+ : [
119
+ ...loaded.warnings,
120
+ catalogWarning(config.id, config.name, "managed-command-unavailable", {
121
+ message: unavailableMessage
122
+ })
123
+ ]
88
124
  };
89
125
  }
90
126
  async function loadedLlamaModels(config, options, baseUrl, aliases) {
@@ -105,7 +141,7 @@ async function loadedLlamaModels(config, options, baseUrl, aliases) {
105
141
  aliases: aliases.filter((alias) => alias.id === model.id).map((alias) => alias.name),
106
142
  displayName: `${config.name} / ${model.id}`,
107
143
  maxTokens: options.maxTokens,
108
- reasoning: managedModelSupportsReasoning(model.id),
144
+ ...managedCapabilityConfig(model.id, options),
109
145
  capabilities: ["text"],
110
146
  availability: "loaded",
111
147
  ...(contextWindow === undefined ? {} : { contextWindow })
@@ -131,7 +167,7 @@ async function startableLlamaModels(config, options, baseUrl, loaded, aliases) {
131
167
  aliases: [alias.name],
132
168
  displayName: `${config.name} / ${alias.name}`,
133
169
  maxTokens: options.maxTokens,
134
- reasoning: managedModelSupportsReasoning(resolved.id),
170
+ ...managedCapabilityConfig(resolved.id, options),
135
171
  capabilities: ["text"],
136
172
  availability: "startable",
137
173
  ...(resolved.contextWindow === undefined ? {} : { contextWindow: resolved.contextWindow })
@@ -143,7 +179,19 @@ async function startableLlamaModels(config, options, baseUrl, loaded, aliases) {
143
179
  }));
144
180
  return startable.filter((model) => model !== undefined);
145
181
  }
146
- function externalReasoningConfig(modelId) {
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) {
147
195
  const normalized = modelId.toLowerCase();
148
196
  if (isDeepSeekThinkingModel(normalized)) {
149
197
  return { reasoning: true, thinkingFormat: "deepseek" };
@@ -151,6 +199,9 @@ function externalReasoningConfig(modelId) {
151
199
  if (isQwenThinkingModel(normalized)) {
152
200
  return { reasoning: true, thinkingFormat: "qwen-chat-template" };
153
201
  }
202
+ if (providerId === "vllm" && isGemmaThinkingModel(normalized)) {
203
+ return { reasoning: true, thinkingFormat: "qwen-chat-template" };
204
+ }
154
205
  return {};
155
206
  }
156
207
  export function managedModelSupportsReasoning(modelId) {
@@ -160,7 +211,33 @@ export function managedModelSupportsReasoning(modelId) {
160
211
  isDeepSeekThinkingModel(normalized) ||
161
212
  isQwenThinkingModel(normalized) ||
162
213
  normalized.includes("gpt-oss") ||
163
- normalized.includes("gemma-4"));
214
+ isGemmaThinkingModel(normalized));
215
+ }
216
+ export function managedCapabilityConfig(modelId, options) {
217
+ return withoutUndefined({
218
+ reasoning: options.modelReasoning ?? managedModelSupportsReasoning(modelId),
219
+ thinkingFormat: options.modelThinkingFormat
220
+ });
221
+ }
222
+ export function runtimeCatalogWarning(providerId, providerName, message) {
223
+ return catalogWarning(providerId, providerName, "runtime-warning", { message });
224
+ }
225
+ export function formatCatalogWarning(warning) {
226
+ switch (warning.code) {
227
+ case "provider-not-responding":
228
+ return `${warning.providerName} is ${warning.message}`;
229
+ case "managed-command-unavailable":
230
+ case "runtime-warning":
231
+ return warning.message;
232
+ }
233
+ }
234
+ function catalogWarning(providerId, providerName, code, options) {
235
+ return {
236
+ providerId,
237
+ providerName,
238
+ code,
239
+ message: options.message
240
+ };
164
241
  }
165
242
  function isDeepSeekThinkingModel(normalizedModelId) {
166
243
  return (normalizedModelId.includes("deepseek") &&
@@ -186,6 +263,25 @@ function isQwenThinkingModel(normalizedModelId) {
186
263
  (normalizedModelId.includes("qwen") &&
187
264
  (normalizedModelId.includes("reason") || normalizedModelId.includes("thinking"))));
188
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
+ }
189
282
  function hasModelToken(normalizedModelId, token) {
190
283
  return normalizedModelId.split(/[^a-z0-9]+/u).includes(token);
191
284
  }
285
+ function withoutUndefined(value) {
286
+ return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined));
287
+ }