localpi 0.5.2 → 0.6.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.
@@ -16,6 +16,22 @@ export async function listModels(baseUrl, timeoutMs = 3000, fetcher = fetch) {
16
16
  .map((entry) => modelInfo(asObject(entry, "model entry")))
17
17
  .filter((model) => model !== undefined);
18
18
  }
19
+ export async function fetchServerProps(baseUrl, timeoutMs = 3000, fetcher = fetch) {
20
+ const response = await fetcher(`${serverRootUrl(baseUrl)}/props`, {
21
+ signal: AbortSignal.timeout(timeoutMs)
22
+ });
23
+ if (!response.ok) {
24
+ throw new Error(`server props failed with HTTP ${String(response.status)}`);
25
+ }
26
+ const payload = await response.json();
27
+ const root = asObject(payload, "server props response");
28
+ const role = optionalString(root["role"]);
29
+ const modelsAutoload = optionalBoolean(root["models_autoload"]);
30
+ return {
31
+ ...(role === undefined ? {} : { role }),
32
+ ...(modelsAutoload === undefined ? {} : { modelsAutoload })
33
+ };
34
+ }
19
35
  export async function resolveLocalModel(baseUrl, requestedModel, timeoutMs = 3000, fetcher = fetch) {
20
36
  const modelInfos = await listModels(baseUrl, timeoutMs, fetcher);
21
37
  const availableModels = modelInfos.map((model) => model.id);
@@ -36,7 +52,47 @@ function modelInfo(entry) {
36
52
  if (id === undefined) {
37
53
  return undefined;
38
54
  }
39
- return withOptionalContextWindow({ id }, findContextWindow(entry));
55
+ const contextWindow = findContextWindow(entry);
56
+ const inputModalities = findInputModalities(entry);
57
+ return {
58
+ id,
59
+ ...optionalModelStatus(modelStatus(entry["status"])),
60
+ ...(contextWindow === undefined ? {} : { contextWindow }),
61
+ ...(inputModalities === undefined ? {} : { inputModalities })
62
+ };
63
+ }
64
+ // llama.cpp reports the model architecture in /v1/models, and a model that accepts images says so
65
+ // in input_modalities. Anything else stays unknown instead of guessed.
66
+ function findInputModalities(entry) {
67
+ const architecture = entry["architecture"];
68
+ if (architecture === null || typeof architecture !== "object" || Array.isArray(architecture)) {
69
+ return undefined;
70
+ }
71
+ const value = architecture["input_modalities"];
72
+ if (!Array.isArray(value)) {
73
+ return undefined;
74
+ }
75
+ const modalities = value.filter((item) => typeof item === "string");
76
+ return modalities.length === 0 ? undefined : modalities;
77
+ }
78
+ function optionalModelStatus(status) {
79
+ return status === undefined ? {} : { status };
80
+ }
81
+ function modelStatus(value) {
82
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
83
+ return undefined;
84
+ }
85
+ const status = optionalString(value["value"]);
86
+ return status === "loaded" || status === "unloaded" ? status : undefined;
87
+ }
88
+ function optionalBoolean(value) {
89
+ return typeof value === "boolean" ? value : undefined;
90
+ }
91
+ // llama.cpp serves /props at the server root, while the OpenAI-compatible API
92
+ // lives under /v1. Strip that one trailing segment when building the props URL.
93
+ function serverRootUrl(baseUrl) {
94
+ const normalized = normalizeBaseUrl(baseUrl);
95
+ return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized;
40
96
  }
41
97
  function withOptionalContextWindow(value, contextWindow) {
42
98
  if (contextWindow === undefined) {
@@ -61,9 +117,14 @@ function findContextWindow(entry) {
61
117
  return value;
62
118
  }
63
119
  }
64
- const metadata = entry["metadata"];
65
- if (metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)) {
66
- return findContextWindow(metadata);
120
+ for (const key of ["metadata", "meta"]) {
121
+ const nested = entry[key];
122
+ if (nested !== null && typeof nested === "object" && !Array.isArray(nested)) {
123
+ const found = findContextWindow(nested);
124
+ if (found !== undefined) {
125
+ return found;
126
+ }
127
+ }
67
128
  }
68
129
  return undefined;
69
130
  }
@@ -0,0 +1,118 @@
1
+ import { spawn } from "node:child_process";
2
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { createPiLaunchPlan, writePiRuntimeConfig } from "@osolmaz/pi-factory";
6
+ const adapterPackage = "pi-acp";
7
+ const adapterEntry = path.join("dist", "index.js");
8
+ const localpiPiCommandError = "ACP mode cannot start localpi as its Pi command; pass a Pi command with --pi-command or LOCALPI_PI_CMD";
9
+ /** The pinned adapter entrypoint inside the installed pi-acp package. */
10
+ export function acpAdapterEntrypoint(env = process.env) {
11
+ const override = env["LOCALPI_ACP_ADAPTER"];
12
+ if (override !== undefined && override !== "") {
13
+ return path.resolve(override);
14
+ }
15
+ const require = createRequire(import.meta.url);
16
+ const packagePath = require.resolve(`${adapterPackage}/package.json`);
17
+ return path.join(path.dirname(packagePath), adapterEntry);
18
+ }
19
+ export async function createAcpSession(app) {
20
+ const adapterEntrypoint = acpAdapterEntrypoint();
21
+ const runtimeConfig = await writePiRuntimeConfig(app);
22
+ await mkdir(app.sessionDir, { recursive: true });
23
+ const plan = await createPiLaunchPlan(app, runtimeConfig);
24
+ assertPiCommandIsNotLocalpi(plan);
25
+ const launcherPath = await writePiLauncher(app.stateDir, plan);
26
+ return {
27
+ command: process.execPath,
28
+ args: [adapterEntrypoint],
29
+ env: { ...plan.env, PI_ACP_PI_COMMAND: launcherPath, LOCALPI_ACP: "0" },
30
+ cwd: plan.cwd,
31
+ launcherPath,
32
+ warnings: plan.warnings
33
+ };
34
+ }
35
+ /**
36
+ * Runs the ACP adapter on the current terminal. The adapter owns stdout, because ACP messages are
37
+ * the only bytes an ACP client accepts there. localpi writes its own diagnostics to stderr.
38
+ */
39
+ export async function runAcpApp(app, options = {}) {
40
+ const session = await createAcpSession(app);
41
+ writeDiagnostics(options.diagnostics ?? []);
42
+ const spawnProcess = options.spawnProcess ?? defaultSpawn;
43
+ return await new Promise((resolve, reject) => {
44
+ spawnProcess(session.command, session.args, {
45
+ stdio: "inherit",
46
+ cwd: session.cwd,
47
+ env: { ...process.env, ...session.env }
48
+ }, {
49
+ onError: reject,
50
+ onExit: (code, signal) => {
51
+ if (signal !== null) {
52
+ process.kill(process.pid, signal);
53
+ return;
54
+ }
55
+ resolve(code ?? 0);
56
+ }
57
+ });
58
+ });
59
+ }
60
+ function defaultSpawn(command, args, options, handlers) {
61
+ const child = spawn(command, [...args], options);
62
+ child.on("error", handlers.onError);
63
+ child.on("exit", handlers.onExit);
64
+ }
65
+ function writeDiagnostics(lines) {
66
+ for (const line of lines) {
67
+ process.stderr.write(`${line}\n`);
68
+ }
69
+ }
70
+ async function writePiLauncher(stateDir, plan) {
71
+ const directory = path.join(stateDir, "acp");
72
+ await mkdir(directory, { recursive: true });
73
+ const launcherPath = path.join(directory, launcherFileName(process.platform));
74
+ await writeFile(launcherPath, launcherContents(plan, process.platform));
75
+ await chmod(launcherPath, 0o755);
76
+ return launcherPath;
77
+ }
78
+ function launcherFileName(platform) {
79
+ return platform === "win32" ? "pi-launcher.cmd" : "pi-launcher.sh";
80
+ }
81
+ /**
82
+ * The launcher forwards the adapter's own Pi arguments after localpi's launch line. Both branches
83
+ * quote every token, so a Pi program or argument that contains a space survives the shell.
84
+ */
85
+ export function launcherContents(plan, platform) {
86
+ if (platform === "win32") {
87
+ const command = [plan.command, ...plan.args].map(quoteWindowsToken).join(" ");
88
+ return `@echo off\r\n${command} %*\r\n`;
89
+ }
90
+ const command = [plan.command, ...plan.args].map(quotePosixToken).join(" ");
91
+ return `#!/bin/sh\nexec ${command} "$@"\n`;
92
+ }
93
+ function quotePosixToken(token) {
94
+ return `'${token.replaceAll("'", "'\\''")}'`;
95
+ }
96
+ function quoteWindowsToken(token) {
97
+ return `"${token.replace(/"/gu, '""')}"`;
98
+ }
99
+ /**
100
+ * A Pi command that points back at localpi would re-enter ACP mode inside the adapter. Refuse that
101
+ * before the adapter starts.
102
+ */
103
+ function assertPiCommandIsNotLocalpi(plan) {
104
+ if (isLocalpiCommand(plan.command) || plan.args.some(isLocalpiCliPath)) {
105
+ throw new Error(localpiPiCommandError);
106
+ }
107
+ }
108
+ function isLocalpiCommand(program) {
109
+ const name = path
110
+ .basename(program)
111
+ .toLowerCase()
112
+ .replace(/\.(js|cjs|mjs|cmd|bat|exe)$/u, "");
113
+ return name === "localpi";
114
+ }
115
+ function isLocalpiCliPath(token) {
116
+ const normalized = token.replaceAll("\\", "/").toLowerCase();
117
+ return /(^|\/)localpi\/(dist\/)?src\/cli\/main\.(js|ts)$/u.test(normalized);
118
+ }
@@ -1,4 +1,4 @@
1
- import { listModels } from "../llm/openai.js";
1
+ import { fetchServerProps, 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
4
  import { loadLocalModelProfile, profileMatchesBaseUrl, profileMatchesModel } from "./model-profile.js";
@@ -16,6 +16,8 @@ async function discoverProvider(config, options, profile) {
16
16
  switch (config.type) {
17
17
  case "openai-compatible":
18
18
  return discoverOpenAiCompatibleProvider(config, options, profile);
19
+ case "llama-cpp":
20
+ return discoverLlamaCppProvider(config, options, profile);
19
21
  case "managed-llama-server":
20
22
  return discoverManagedLlamaProvider(config, options);
21
23
  }
@@ -52,7 +54,7 @@ async function discoverOpenAiCompatibleProvider(config, options, profile) {
52
54
  };
53
55
  }
54
56
  }
55
- function openAiCatalogModel(config, model, options, profile) {
57
+ function openAiCatalogModel(config, model, options, profile, availability = "loaded") {
56
58
  const baseUrl = config.baseUrl ?? "";
57
59
  const profileConfig = profileCapabilityConfig(profile, baseUrl, model.id);
58
60
  const aliases = profileAliases(profile, baseUrl, model.id);
@@ -67,11 +69,76 @@ function openAiCatalogModel(config, model, options, profile) {
67
69
  displayName: `${config.name} / ${model.id}`,
68
70
  maxTokens: profileConfig.maxTokens ?? options.maxTokens,
69
71
  ...externalCapabilityConfig(config.id, model.id, profileConfig, options),
70
- capabilities: ["text"],
71
- availability: "loaded",
72
+ capabilities: modelCapabilities(model, profileConfig),
73
+ availability,
72
74
  ...(contextWindow === undefined ? {} : { contextWindow })
73
75
  };
74
76
  }
77
+ async function discoverLlamaCppProvider(config, options, profile) {
78
+ const baseUrl = config.baseUrl;
79
+ if (baseUrl === undefined) {
80
+ return { models: [], warnings: [] };
81
+ }
82
+ if (!config.discover) {
83
+ const explicitModel = explicitOpenAiCatalogModel(config, [], options, profile);
84
+ return { models: explicitModel === undefined ? [] : [explicitModel], warnings: [] };
85
+ }
86
+ let models;
87
+ try {
88
+ models = await listModels(baseUrl, options.timeoutMs);
89
+ }
90
+ catch (error) {
91
+ if (explicitOpenAiProviderSelected(options, config.id)) {
92
+ throw error;
93
+ }
94
+ return {
95
+ models: [],
96
+ warnings: [
97
+ catalogWarning(config.id, config.name, "provider-not-responding", {
98
+ message: `not responding at ${baseUrl}`
99
+ })
100
+ ]
101
+ };
102
+ }
103
+ const loaded = models.filter((model) => model.status !== "unloaded");
104
+ const unloaded = models.filter((model) => model.status === "unloaded");
105
+ const autoload = await llamaCppAutoload(baseUrl, options);
106
+ const startable = autoload === true ? unloaded : [];
107
+ return {
108
+ models: [
109
+ ...loaded.map((model) => openAiCatalogModel(config, model, options, profile, "loaded")),
110
+ ...startable.map((model) => openAiCatalogModel(config, model, options, profile, "startable"))
111
+ ],
112
+ warnings: llamaCppUnloadedWarnings(config, unloaded, autoload)
113
+ };
114
+ }
115
+ // Pi passes image input to a model only when the model catalog says the model takes images. A
116
+ // llama.cpp server reports that in the model architecture, and a model profile can state it for a
117
+ // server that reports nothing. No model name is used to guess it.
118
+ function modelCapabilities(model, profileConfig) {
119
+ const image = profileConfig.image ?? model.inputModalities?.includes("image") === true;
120
+ return image ? ["text", "image"] : ["text"];
121
+ }
122
+ async function llamaCppAutoload(baseUrl, options) {
123
+ try {
124
+ return (await fetchServerProps(baseUrl, options.timeoutMs)).modelsAutoload;
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
130
+ function llamaCppUnloadedWarnings(config, unloaded, autoload) {
131
+ if (unloaded.length === 0 || autoload === true) {
132
+ return [];
133
+ }
134
+ return [
135
+ catalogWarning(config.id, config.name, "runtime-warning", {
136
+ message: `${config.name} reported unloaded models: ${unloaded
137
+ .map((model) => model.id)
138
+ .join(", ")}; the server does not autoload models`
139
+ })
140
+ ];
141
+ }
75
142
  function profileAliases(profile, baseUrl, modelId) {
76
143
  if (profile === undefined ||
77
144
  !profileMatchesBaseUrl(profile, baseUrl) ||
@@ -103,6 +170,7 @@ function explicitOpenAiProviderSelected(options, providerId) {
103
170
  (options.provider === undefined &&
104
171
  (options.runtime === "lmstudio" ||
105
172
  options.runtime === "vllm" ||
173
+ options.runtime === "llama-cpp" ||
106
174
  options.runtime === "openai-compatible")));
107
175
  }
108
176
  async function discoverManagedLlamaProvider(config, options) {
@@ -263,14 +331,18 @@ function isQwenThinkingModel(normalizedModelId) {
263
331
  (normalizedModelId.includes("qwen") &&
264
332
  (normalizedModelId.includes("reason") || normalizedModelId.includes("thinking"))));
265
333
  }
334
+ function profileApplies(profile, baseUrl, modelId) {
335
+ return (profile !== undefined &&
336
+ profileMatchesBaseUrl(profile, baseUrl) &&
337
+ profileMatchesModel(profile, modelId));
338
+ }
266
339
  function profileCapabilityConfig(profile, baseUrl, modelId) {
267
- if (profile === undefined ||
268
- !profileMatchesBaseUrl(profile, baseUrl) ||
269
- !profileMatchesModel(profile, modelId)) {
340
+ if (!profileApplies(profile, baseUrl, modelId)) {
270
341
  return {};
271
342
  }
272
343
  return withoutUndefined({
273
344
  reasoning: profile.capabilities?.reasoning,
345
+ image: profile.capabilities?.image,
274
346
  thinkingFormat: profile.capabilities?.thinkingFormat,
275
347
  contextWindow: profile.client?.contextWindow,
276
348
  maxTokens: profile.client?.maxTokens
@@ -0,0 +1,64 @@
1
+ // Catppuccin Mocha palette. Source: https://catppuccin.com/palette (MIT).
2
+ //
3
+ // The palette has two consumers: the Pi theme localpi writes for each session, and localpi's own
4
+ // terminal output. Keep both on this palette so the launcher and the session match.
5
+ export const catppuccinMocha = {
6
+ rosewater: "#f5e0dc",
7
+ flamingo: "#f2cdcd",
8
+ pink: "#f5c2e7",
9
+ mauve: "#cba6f7",
10
+ red: "#f38ba8",
11
+ maroon: "#eba0ac",
12
+ peach: "#fab387",
13
+ yellow: "#f9e2af",
14
+ green: "#a6e3a1",
15
+ teal: "#94e2d5",
16
+ sky: "#89dceb",
17
+ sapphire: "#74c7ec",
18
+ blue: "#89b4fa",
19
+ lavender: "#b4befe",
20
+ text: "#cdd6f4",
21
+ subtext1: "#bac2de",
22
+ subtext0: "#a6adc8",
23
+ overlay2: "#9399b2",
24
+ overlay1: "#7f849c",
25
+ overlay0: "#6c7086",
26
+ surface2: "#585b70",
27
+ surface1: "#45475a",
28
+ surface0: "#313244",
29
+ base: "#1e1e2e",
30
+ mantle: "#181825",
31
+ crust: "#11111b"
32
+ };
33
+ const ansiReset = "\u001B[0m";
34
+ export function colorsEnabled(stream = "stdout") {
35
+ if (forcedColor()) {
36
+ return true;
37
+ }
38
+ if (noColor()) {
39
+ return false;
40
+ }
41
+ const target = stream === "stderr" ? process.stderr : process.stdout;
42
+ // Node types mark isTTY as a boolean, but a pipe leaves it undefined at runtime.
43
+ return Boolean(target.isTTY);
44
+ }
45
+ export function paint(text, color, stream = "stdout") {
46
+ if (text === "" || !colorsEnabled(stream)) {
47
+ return text;
48
+ }
49
+ return `${trueColor(catppuccinMocha[color])}${text}${ansiReset}`;
50
+ }
51
+ function forcedColor() {
52
+ const value = process.env["FORCE_COLOR"];
53
+ return value !== undefined && value !== "" && value !== "0";
54
+ }
55
+ function noColor() {
56
+ const value = process.env["NO_COLOR"];
57
+ return (value !== undefined && value !== "") || process.env["TERM"] === "dumb";
58
+ }
59
+ function trueColor(hex) {
60
+ const red = Number.parseInt(hex.slice(1, 3), 16);
61
+ const green = Number.parseInt(hex.slice(3, 5), 16);
62
+ const blue = Number.parseInt(hex.slice(5, 7), 16);
63
+ return `\u001B[38;2;${String(red)};${String(green)};${String(blue)}m`;
64
+ }
@@ -85,15 +85,16 @@ export async function stopManagedLlamaServer(options) {
85
85
  await rm(metadataPath(options), { force: true });
86
86
  return `stopped localpi-owned llama-server pid ${String(info.pid)}`;
87
87
  }
88
+ import { paint } from "./catppuccin.js";
88
89
  export async function llamaServerStatus(options) {
89
90
  const baseUrl = llamaBaseUrl(options);
90
91
  const info = await readActiveMetadataFile(options);
91
92
  const models = await getLlamaServerModels(options);
92
93
  return [
93
- `runtime: llama-server`,
94
- `base url: ${baseUrl}`,
95
- `metadata: ${info === undefined ? "none" : metadataSummary(info)}`,
96
- `server: ${models === undefined ? "not responding" : models.map((model) => model.id).join(", ")}`
94
+ `${paint("runtime:", "overlay1")} llama-server`,
95
+ `${paint("base url:", "overlay1")} ${baseUrl}`,
96
+ `${paint("metadata:", "overlay1")} ${info === undefined ? "none" : metadataSummary(info)}`,
97
+ `${paint("server:", "overlay1")} ${models === undefined ? "not responding" : models.map((model) => model.id).join(", ")}`
97
98
  ].join("\n");
98
99
  }
99
100
  export function llamaBaseUrl(options) {
@@ -213,7 +214,7 @@ function serverArgs(options, model) {
213
214
  "--gpu-layers",
214
215
  String(options.gpuLayers),
215
216
  ...chatTemplateArgs(model.chatTemplate),
216
- ...reasoningArgs(options.thinking),
217
+ ...reasoningArgs(reasoningFor(options)),
217
218
  "--reasoning-format",
218
219
  "deepseek",
219
220
  "--metrics"
@@ -222,11 +223,15 @@ function serverArgs(options, model) {
222
223
  function chatTemplateArgs(chatTemplate) {
223
224
  return chatTemplate === undefined ? [] : ["--chat-template-file", chatTemplate];
224
225
  }
225
- function reasoningArgs(thinking) {
226
- const config = reasoningConfig(thinking);
227
- return config.budget === undefined
228
- ? ["--reasoning", config.mode]
229
- : ["--reasoning", config.mode, "--reasoning-budget", String(config.budget)];
226
+ function reasoningArgs(config) {
227
+ const args = ["--reasoning", config.mode];
228
+ if (config.budget !== undefined) {
229
+ args.push("--reasoning-budget", String(config.budget));
230
+ }
231
+ if (config.message) {
232
+ args.push("--reasoning-budget-message", config.message);
233
+ }
234
+ return args;
230
235
  }
231
236
  async function waitForModels(baseUrl, modelId, timeoutMs) {
232
237
  const deadline = Date.now() + timeoutMs;
@@ -273,7 +278,7 @@ function metadata(options, model, pid) {
273
278
  port: endpoint.port,
274
279
  gpuLayers: options.gpuLayers,
275
280
  parallel: options.parallel,
276
- ...reasoningMetadata(options.thinking),
281
+ ...reasoningMetadata(reasoningFor(options)),
277
282
  ...optionalChatTemplate(model.chatTemplate)
278
283
  };
279
284
  }
@@ -376,13 +381,15 @@ export function managedLlamaServerNeedsRestart(options, info, model) {
376
381
  info.port !== endpoint.port,
377
382
  info.gpuLayers !== options.gpuLayers,
378
383
  info.parallel !== options.parallel,
379
- reasoningChanged(options.thinking, info)
384
+ reasoningChanged(options, info)
380
385
  ].some(Boolean);
381
386
  return fieldsChanged || chatTemplateChanged(options, info) || modelChanged(options, info, model);
382
387
  }
383
- function reasoningChanged(thinking, info) {
384
- const expected = reasoningConfig(thinking);
385
- return info.reasoningMode !== expected.mode || info.reasoningBudget !== expected.budget;
388
+ function reasoningChanged(options, info) {
389
+ const expected = reasoningFor(options);
390
+ return (info.reasoningMode !== expected.mode ||
391
+ info.reasoningBudget !== expected.budget ||
392
+ info.reasoningMessage !== expected.message);
386
393
  }
387
394
  function chatTemplateChanged(options, info) {
388
395
  return options.chatTemplate !== undefined && info.chatTemplate !== options.chatTemplate;
@@ -480,33 +487,59 @@ function commandMatchesMetadata(command, info) {
480
487
  return (command.includes(info.modelPath) &&
481
488
  commandMarkers(info.serverCommand).some((marker) => command.includes(marker)));
482
489
  }
483
- function reasoningConfig(thinking) {
484
- switch (thinking) {
485
- case "off":
486
- return { mode: "off" };
487
- case "minimal":
488
- return { mode: "on", budget: 32 };
489
- case "low":
490
- return { mode: "on", budget: 128 };
491
- case "medium":
492
- return { mode: "on", budget: 512 };
493
- case "high":
494
- return { mode: "on", budget: 2048 };
495
- case "xhigh":
496
- return { mode: "on", budget: 8192 };
497
- }
498
- }
499
- function reasoningMetadata(thinking) {
500
- const config = reasoningConfig(thinking);
501
- return config.budget === undefined
502
- ? { reasoningMode: config.mode }
503
- : { reasoningMode: config.mode, reasoningBudget: config.budget };
490
+ /** Thinking token budgets per level, in tokens. */
491
+ const thinkingBudgets = {
492
+ minimal: 32,
493
+ low: 128,
494
+ medium: 512,
495
+ high: 2048,
496
+ xhigh: 16384
497
+ };
498
+ /**
499
+ * Injected before the end-of-thinking tag when the budget runs out. Without it the model is cut off
500
+ * mid-thought, and a model that loops in its thinking never reaches an answer. Set
501
+ * `--thinking-budget-message` or `LOCALPI_THINKING_BUDGET_MESSAGE` to reword it, or to an empty
502
+ * string to pass no message at all.
503
+ */
504
+ export const reasoningBudgetMessage = "Reasoning budget reached. Stop thinking and answer now.";
505
+ /**
506
+ * The reasoning flags of one thinking level. A budget override replaces the budget of the level,
507
+ * where -1 leaves thinking unrestricted. Thinking stays off when the level is off, and the message
508
+ * is set only for a finite budget, because an unrestricted budget has nothing to cut short. A
509
+ * message override rewords the default, and an empty override passes no message flag.
510
+ */
511
+ export function reasoningConfig(thinking, budgetOverride, messageOverride) {
512
+ if (thinking === "off") {
513
+ return { mode: "off" };
514
+ }
515
+ const budget = budgetOverride ?? thinkingBudgets[thinking];
516
+ if (budget < 0) {
517
+ return { mode: "on", budget };
518
+ }
519
+ const message = messageOverride ?? reasoningBudgetMessage;
520
+ return message === "" ? { mode: "on", budget } : { mode: "on", budget, message };
521
+ }
522
+ function reasoningFor(options) {
523
+ return reasoningConfig(options.thinking, options.thinkingBudget, options.thinkingBudgetMessage);
524
+ }
525
+ function reasoningMetadata(config) {
526
+ return {
527
+ reasoningMode: config.mode,
528
+ ...(config.budget === undefined ? {} : { reasoningBudget: config.budget }),
529
+ ...(config.message ? { reasoningMessage: config.message } : {})
530
+ };
504
531
  }
505
532
  function parseReasoningMetadata(value) {
506
533
  const mode = value.reasoningMode === "on" ? "on" : "off";
507
- return value.reasoningBudget === undefined
508
- ? { reasoningMode: mode }
509
- : { reasoningMode: mode, reasoningBudget: metadataNumber(value.reasoningBudget) };
534
+ return {
535
+ reasoningMode: mode,
536
+ ...(value.reasoningBudget === undefined
537
+ ? {}
538
+ : { reasoningBudget: metadataNumber(value.reasoningBudget) }),
539
+ ...(value.reasoningMessage === undefined || value.reasoningMessage === ""
540
+ ? {}
541
+ : { reasoningMessage: value.reasoningMessage })
542
+ };
510
543
  }
511
544
  function reasoningSummary(info) {
512
545
  return info.reasoningBudget === undefined
@@ -32,6 +32,9 @@ function parseLocalModelProfile(value, source) {
32
32
  const thinkingFormat = capabilities === undefined
33
33
  ? undefined
34
34
  : optionalThinkingFormat(optionalProfileString(capabilities["thinking_format"], `model profile ${source} capabilities.thinking_format`), `model profile ${source} capabilities.thinking_format`);
35
+ const image = capabilities === undefined
36
+ ? undefined
37
+ : optionalBoolean(capabilities["image"], `model profile ${source} capabilities.image`);
35
38
  return withoutUndefined({
36
39
  id: requiredString(root["id"], `model profile ${source} id`),
37
40
  model: requiredString(root["model"], `model profile ${source} model`),
@@ -46,6 +49,7 @@ function parseLocalModelProfile(value, source) {
46
49
  ? undefined
47
50
  : withoutUndefined({
48
51
  reasoning,
52
+ image,
49
53
  thinkingFormat
50
54
  })
51
55
  });