localpi 0.5.0 → 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.
- package/README.md +389 -11
- package/dist/src/cli/cli.js +115 -6
- package/dist/src/cli/main.js +0 -0
- package/dist/src/llm/openai.js +65 -4
- package/dist/src/localpi/acp.js +118 -0
- package/dist/src/localpi/catalog.js +79 -7
- package/dist/src/localpi/catppuccin.js +64 -0
- package/dist/src/localpi/llama-server.js +72 -39
- package/dist/src/localpi/model-profile.js +4 -0
- package/dist/src/localpi/options.js +129 -9
- package/dist/src/localpi/provider-registry.js +51 -3
- package/dist/src/localpi/runtime-connection.js +11 -8
- package/dist/src/localpi/runtime.js +12 -7
- package/dist/src/localpi/settings-state.js +13 -3
- package/dist/src/pi/app.js +13 -6
- package/dist/src/pi/extension-sources/continue-on-truncation.js +55 -0
- package/dist/src/pi/extension-sources/settings-file.js +31 -0
- package/dist/src/pi/extension-sources/status-line.js +424 -0
- package/dist/src/pi/extension-sources/thinking-control.js +4 -47
- package/dist/src/pi/extension-sources/token-status.js +545 -116
- package/dist/src/pi/extension-sources/tool-approval.js +155 -14
- package/dist/src/pi/extensions.js +55 -12
- package/dist/src/pi/skills.js +24 -0
- package/dist/src/pi/theme.js +107 -0
- package/docs/2026-06-16-startup-model-and-thinking-control-plan.md +39 -11
- package/docs/2026-09-23-acp-mode-plan.md +111 -0
- package/docs/2026-09-24-continue-on-truncation-plan.md +115 -0
- package/docs/design-principles.md +114 -0
- package/docs/implementation-plan.md +33 -0
- package/docs/runtime-specification.md +98 -4
- package/package.json +11 -7
- package/dist/src/pi/extension-sources/demo-mode.js +0 -110
package/dist/src/llm/openai.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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:
|
|
71
|
-
availability
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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(
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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
|
|
384
|
+
reasoningChanged(options, info)
|
|
380
385
|
].some(Boolean);
|
|
381
386
|
return fieldsChanged || chatTemplateChanged(options, info) || modelChanged(options, info, model);
|
|
382
387
|
}
|
|
383
|
-
function reasoningChanged(
|
|
384
|
-
const expected =
|
|
385
|
-
return info.reasoningMode !== expected.mode ||
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
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
|
});
|