localpi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dutifuldev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,253 @@
1
+ # localpi
2
+
3
+ Localpi is a local Pi launcher for open-weight models.
4
+
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
+
7
+ Localpi supports LM Studio, vLLM, custom OpenAI-compatible servers, and a managed `llama-server` fallback.
8
+
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
+
11
+ See:
12
+
13
+ - [Runtime Specification](docs/runtime-specification.md)
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install -g localpi
19
+ ```
20
+
21
+ During development:
22
+
23
+ ```bash
24
+ npm run localpi -- --status
25
+ ```
26
+
27
+ After build:
28
+
29
+ ```bash
30
+ node dist/src/cli/main.js --status
31
+ ```
32
+
33
+ ## Runtime Model
34
+
35
+ Target default:
36
+
37
+ ```bash
38
+ localpi --model gemma-12b
39
+ ```
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.
42
+
43
+ LM Studio is explicit:
44
+
45
+ ```bash
46
+ localpi --runtime lmstudio --model gemma-4-e4b-it
47
+ ```
48
+
49
+ vLLM is explicit:
50
+
51
+ ```bash
52
+ localpi --runtime vllm --model qwen
53
+ ```
54
+
55
+ Custom OpenAI-compatible endpoints are also supported:
56
+
57
+ ```bash
58
+ localpi --runtime openai-compatible --base-url http://127.0.0.1:8000/v1 --model my-model
59
+ ```
60
+
61
+ Use `--provider <id>` with `--model <id>` to select a catalog entry without opening the picker. `--provider <id>` by itself only scopes the available choices. Localpi avoids loading multiple heavyweight local runtimes at the same time. When using the managed `llama-server` runtime, it either stops its previous managed server or clearly reports what is already running before starting another model.
62
+
63
+ ## Default Pi Behavior
64
+
65
+ Localpi launches Pi with:
66
+
67
+ - default tools: `read,bash,edit,write,grep,find,ls`
68
+ - a system prompt that explains local tool approval and local-model limits
69
+ - an approval gate before every tool call
70
+ - token speed and token count status while responses stream
71
+ - bounded Gemma/llama-server reasoning controlled by `--thinking`
72
+ - an in-session `/thinking` command for changing Pi's active thinking level
73
+ - local state under `~/.local/state/localpi`
74
+
75
+ The approval gate makes failed or denied tool calls explicit to the model so the model does not claim that a blocked command ran.
76
+
77
+ ## LM Studio Alternative
78
+
79
+ LM Studio exposes an OpenAI-compatible endpoint, usually:
80
+
81
+ ```text
82
+ http://127.0.0.1:1234/v1
83
+ ```
84
+
85
+ Load Gemma in LM Studio:
86
+
87
+ ```bash
88
+ ~/.lmstudio/bin/lms server start
89
+ ~/.lmstudio/bin/lms load gemma-4-e4b-it -y
90
+ ```
91
+
92
+ Then run localpi against LM Studio explicitly:
93
+
94
+ ```bash
95
+ localpi --runtime lmstudio --model gemma-4-e4b-it
96
+ ```
97
+
98
+ ## Usage
99
+
100
+ Run Pi interactively on the default local model:
101
+
102
+ ```bash
103
+ localpi
104
+ ```
105
+
106
+ Run a non-interactive Pi prompt:
107
+
108
+ ```bash
109
+ localpi -p "summarize this repo"
110
+ ```
111
+
112
+ Pin a model alias:
113
+
114
+ ```bash
115
+ localpi --model gemma-e4b -p "write a detailed implementation plan"
116
+ ```
117
+
118
+ Use a bounded reasoning budget with managed `llama-server`:
119
+
120
+ ```bash
121
+ localpi --model gemma-12b --thinking low -p "classify this item"
122
+ ```
123
+
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.
125
+
126
+ For managed `llama-server`, thinking levels map to server-side reasoning:
127
+
128
+ | Level | llama-server reasoning |
129
+ | --------- | ---------------------------------------- |
130
+ | `off` | `--reasoning off` |
131
+ | `minimal` | `--reasoning on --reasoning-budget 32` |
132
+ | `low` | `--reasoning on --reasoning-budget 128` |
133
+ | `medium` | `--reasoning on --reasoning-budget 512` |
134
+ | `high` | `--reasoning on --reasoning-budget 2048` |
135
+ | `xhigh` | `--reasoning on --reasoning-budget 8192` |
136
+
137
+ The default is `off`.
138
+
139
+ Point at vLLM:
140
+
141
+ ```bash
142
+ localpi --runtime vllm --model qwen -p "review the src directory"
143
+ ```
144
+
145
+ Point at a different OpenAI-compatible local server:
146
+
147
+ ```bash
148
+ localpi --runtime openai-compatible --base-url http://127.0.0.1:8000/v1 -p "review the src directory"
149
+ ```
150
+
151
+ Pass a Pi flag that localpi also owns after `--`:
152
+
153
+ ```bash
154
+ localpi --model gemma-e4b -- --model some-pi-level-value
155
+ ```
156
+
157
+ Stop the managed `llama-server` runtime:
158
+
159
+ ```bash
160
+ localpi --stop
161
+ ```
162
+
163
+ ## Options
164
+
165
+ - `--runtime <auto|llama-server|lmstudio|vllm|openai-compatible>`: runtime backend. Default: `auto`
166
+ - `--provider <id>`: catalog provider id to use, for example `lmstudio` or `vllm`
167
+ - `--model <alias|id|path|auto>`: model alias, model id, or GGUF path
168
+ - `--ctx <n>` / `--context-window <n>`: model context window
169
+ - `--max-tokens <n>`: generated model max output tokens
170
+ - `--base-url <url>`: OpenAI-compatible endpoint for LM Studio or custom endpoints
171
+ - `--server-command <path>`: `llama-server` executable path
172
+ - `--llama-server <path>`: alias for `--server-command`
173
+ - `--host <host>`: managed `llama-server` host. Default: `127.0.0.1`
174
+ - `--port <n>`: managed `llama-server` port. Default: `18194`
175
+ - `--gpu-layers <n>`: managed `llama-server` GPU layers. Default: `999`
176
+ - `--parallel <n>`: managed `llama-server` parallel slots. Default: `1`
177
+ - `--chat-template <path>`: optional llama.cpp chat template file
178
+ - `--state-dir <path>`: runtime state directory. Default: `~/.local/state/localpi`
179
+ - `--session-dir <path>`: Pi session directory. Default: `<state-dir>/sessions`
180
+ - `--pi-command <command>`: Pi launch command
181
+ - `--providers-file <path>`: provider registry JSON
182
+ - `--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`
184
+ - `--no-approval`: disable the tool approval gate
185
+ - `--no-token-status`: disable the token status extension
186
+ - `--status`: print runtime, model, and Pi config status
187
+ - `--stop`: stop the managed `llama-server` process
188
+ - `--list`: list configured model aliases
189
+
190
+ ## Environment
191
+
192
+ - `LOCALPI_RUNTIME`
193
+ - `LOCALPI_MODEL`
194
+ - `LOCALPI_PROVIDER`
195
+ - `LOCALPI_BASE_URL`
196
+ - `LOCALPI_PROVIDERS_FILE`
197
+ - `LOCALPI_STATE_DIR`
198
+ - `LOCALPI_SESSION_DIR`
199
+ - `LOCALPI_PI_CMD`
200
+ - `LOCALPI_CONTEXT_WINDOW`
201
+ - `LOCALPI_MAX_TOKENS`
202
+ - `LOCALPI_LLAMA_SERVER`
203
+ - `LOCALPI_HOST`
204
+ - `LOCALPI_PORT`
205
+ - `LOCALPI_GPU_LAYERS`
206
+ - `LOCALPI_PARALLEL`
207
+ - `LOCALPI_CHAT_TEMPLATE`
208
+ - `LOCALPI_TOOLS`
209
+ - `LOCALPI_THINKING`
210
+ - `LOCALPI_MODELS_FILE`
211
+
212
+ `LOCALPI_MODELS_FILE` may point at a JSON file with this shape:
213
+
214
+ ```json
215
+ {
216
+ "models": {
217
+ "my-model": {
218
+ "id": "my-model-id",
219
+ "path": "/path/to/model.gguf",
220
+ "contextWindow": 32768,
221
+ "chatTemplate": "/path/to/template.jinja"
222
+ }
223
+ }
224
+ }
225
+ ```
226
+
227
+ Provider registries use the same file or `LOCALPI_PROVIDERS_FILE`:
228
+
229
+ ```json
230
+ {
231
+ "providers": {
232
+ "vllm-qwen": {
233
+ "type": "openai-compatible",
234
+ "name": "vLLM Qwen",
235
+ "baseUrl": "http://127.0.0.1:8000/v1",
236
+ "discover": true
237
+ }
238
+ }
239
+ }
240
+ ```
241
+
242
+ 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
+
244
+ ## Development
245
+
246
+ ```bash
247
+ npm run format
248
+ npm run lint
249
+ npm run typecheck
250
+ npm test
251
+ npm run build
252
+ npm run check
253
+ ```
@@ -0,0 +1,57 @@
1
+ import { errorMessage, fail, ok } from "../common/result.js";
2
+ import { parseLocalpiArgs, usage } from "../localpi/options.js";
3
+ import { aliasListOutput, connectionStatus, resolveRuntime, statusOutput, stopRuntime } from "../localpi/runtime.js";
4
+ import { writeRuntimeConfig } from "../pi/config.js";
5
+ import { writeDefaultExtensions } from "../pi/extensions.js";
6
+ import { createLaunchPlan, execLaunchPlan } from "../pi/launch.js";
7
+ export async function run(args) {
8
+ try {
9
+ const options = parseLocalpiArgs(args);
10
+ const commandResult = await immediateCommandResult(options);
11
+ if (commandResult !== undefined) {
12
+ return commandResult;
13
+ }
14
+ const connection = await resolveRuntime(options);
15
+ const runtimeConfig = await writeRuntimeConfig(options, connection);
16
+ const selectorOptions = startupModelSelectorOptions(options, connection);
17
+ 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));
24
+ }
25
+ catch (error) {
26
+ return fail(`localpi: ${errorMessage(error)}`);
27
+ }
28
+ }
29
+ async function immediateCommandResult(options) {
30
+ if (options.forwardedArgs.length === 1 && options.forwardedArgs[0] === "--help") {
31
+ return ok(usage());
32
+ }
33
+ if (options.list) {
34
+ return ok(`${await aliasListOutput()}\n`);
35
+ }
36
+ if (options.stop) {
37
+ return ok(`${await stopRuntime(options)}\n`);
38
+ }
39
+ return options.status ? ok(`${await statusOutput(options)}\n`) : undefined;
40
+ }
41
+ function startupModelSelectorOptions(options, connection) {
42
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
43
+ return undefined;
44
+ }
45
+ if (options.model !== undefined && options.model !== "auto") {
46
+ return undefined;
47
+ }
48
+ const scopedProviderId = options.provider === undefined ? undefined : connection.providerId;
49
+ const loadedModels = connection.catalogModels.filter((model) => model.availability === "loaded" &&
50
+ (scopedProviderId === undefined || model.providerId === scopedProviderId));
51
+ if (loadedModels.length <= 1) {
52
+ return undefined;
53
+ }
54
+ return {
55
+ models: loadedModels.map((model) => ({ provider: model.providerId, id: model.modelId }))
56
+ };
57
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "./cli.js";
3
+ try {
4
+ const result = await run(process.argv.slice(2));
5
+ process.stdout.write(result.stdout);
6
+ process.stderr.write(result.stderr);
7
+ process.exitCode = result.code;
8
+ }
9
+ catch (error) {
10
+ process.stderr.write(error instanceof Error ? `${error.message}\n` : `${String(error)}\n`);
11
+ process.exitCode = 2;
12
+ }
@@ -0,0 +1,21 @@
1
+ export function asObject(value, context) {
2
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
3
+ throw new Error(`${context} must be an object`);
4
+ }
5
+ return value;
6
+ }
7
+ export function optionalString(value) {
8
+ return typeof value === "string" ? value : undefined;
9
+ }
10
+ export function requiredString(value, context) {
11
+ if (typeof value !== "string") {
12
+ throw new Error(`${context} must be a string`);
13
+ }
14
+ return value;
15
+ }
16
+ export function asArray(value, context) {
17
+ if (!Array.isArray(value)) {
18
+ throw new Error(`${context} must be an array`);
19
+ }
20
+ return value;
21
+ }
@@ -0,0 +1,9 @@
1
+ export const ok = (stdout = "") => ({ code: 0, stdout, stderr: "" });
2
+ export const fail = (stderr, code = 2) => ({
3
+ code,
4
+ stdout: "",
5
+ stderr: stderr.endsWith("\n") ? stderr : `${stderr}\n`
6
+ });
7
+ export function errorMessage(error) {
8
+ return error instanceof Error ? error.message : String(error);
9
+ }
@@ -0,0 +1,78 @@
1
+ import { asArray, asObject, optionalString } from "../common/json.js";
2
+ export function normalizeBaseUrl(value) {
3
+ return value.replace(/\/+$/u, "");
4
+ }
5
+ export async function listModels(baseUrl, timeoutMs = 3000, fetcher = fetch) {
6
+ const response = await fetcher(`${normalizeBaseUrl(baseUrl)}/models`, {
7
+ signal: AbortSignal.timeout(timeoutMs)
8
+ });
9
+ if (!response.ok) {
10
+ throw new Error(`model list failed with HTTP ${String(response.status)}`);
11
+ }
12
+ const payload = await response.json();
13
+ const root = asObject(payload, "models response");
14
+ const data = asArray(root["data"], "models response data");
15
+ return data
16
+ .map((entry) => modelInfo(asObject(entry, "model entry")))
17
+ .filter((model) => model !== undefined);
18
+ }
19
+ export async function resolveLocalModel(baseUrl, requestedModel, timeoutMs = 3000, fetcher = fetch) {
20
+ const modelInfos = await listModels(baseUrl, timeoutMs, fetcher);
21
+ const availableModels = modelInfos.map((model) => model.id);
22
+ if (requestedModel === "auto") {
23
+ const first = modelInfos[0];
24
+ if (first === undefined) {
25
+ throw new Error(`no models returned by ${normalizeBaseUrl(baseUrl)}/models`);
26
+ }
27
+ return withOptionalContextWindow({ model: first.id, availableModels }, first.contextWindow);
28
+ }
29
+ if (availableModels.length > 0 && !availableModels.includes(requestedModel)) {
30
+ throw new Error(`model ${requestedModel} is not reported by ${normalizeBaseUrl(baseUrl)}/models; available: ${availableModels.join(", ")}`);
31
+ }
32
+ return withOptionalContextWindow({ model: requestedModel, availableModels }, modelInfos.find((model) => model.id === requestedModel)?.contextWindow);
33
+ }
34
+ function modelInfo(entry) {
35
+ const id = optionalString(entry["id"]);
36
+ if (id === undefined) {
37
+ return undefined;
38
+ }
39
+ return withOptionalContextWindow({ id }, findContextWindow(entry));
40
+ }
41
+ function withOptionalContextWindow(value, contextWindow) {
42
+ if (contextWindow === undefined) {
43
+ return value;
44
+ }
45
+ return { ...value, contextWindow };
46
+ }
47
+ function findContextWindow(entry) {
48
+ for (const key of [
49
+ "context_window",
50
+ "contextWindow",
51
+ "context_length",
52
+ "contextLength",
53
+ "max_context_length",
54
+ "maxContextLength",
55
+ "n_ctx",
56
+ "max_input_tokens",
57
+ "maxInputTokens"
58
+ ]) {
59
+ const value = positiveInteger(entry[key]);
60
+ if (value !== undefined) {
61
+ return value;
62
+ }
63
+ }
64
+ const metadata = entry["metadata"];
65
+ if (metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)) {
66
+ return findContextWindow(metadata);
67
+ }
68
+ return undefined;
69
+ }
70
+ function positiveInteger(value) {
71
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) {
72
+ return value;
73
+ }
74
+ if (typeof value === "string" && /^[1-9]\d*$/u.test(value)) {
75
+ return Number.parseInt(value, 10);
76
+ }
77
+ return undefined;
78
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,191 @@
1
+ import { listModels } from "../llm/openai.js";
2
+ import { getManagedLlamaServerMetadata, getLlamaServerModels, llamaBaseUrl } from "./llama-server.js";
3
+ import { listModelAliases, resolveLlamaModel } from "./models.js";
4
+ import { providerConfigs } from "./provider-registry.js";
5
+ export async function discoverModelCatalog(options) {
6
+ const configs = await providerConfigs(options);
7
+ const discovered = await Promise.all(configs.map((config) => discoverProvider(config, options)));
8
+ return {
9
+ models: discovered.flatMap((entry) => entry.models),
10
+ warnings: discovered.flatMap((entry) => entry.warnings)
11
+ };
12
+ }
13
+ async function discoverProvider(config, options) {
14
+ switch (config.type) {
15
+ case "openai-compatible":
16
+ return discoverOpenAiCompatibleProvider(config, options);
17
+ case "managed-llama-server":
18
+ return discoverManagedLlamaProvider(config, options);
19
+ }
20
+ }
21
+ async function discoverOpenAiCompatibleProvider(config, options) {
22
+ if (config.baseUrl === undefined) {
23
+ return { models: [], warnings: [] };
24
+ }
25
+ if (!config.discover) {
26
+ const explicitModel = explicitOpenAiCatalogModel(config, [], options);
27
+ return { models: explicitModel === undefined ? [] : [explicitModel], warnings: [] };
28
+ }
29
+ try {
30
+ const models = await listModels(config.baseUrl, options.timeoutMs);
31
+ const explicitModel = explicitOpenAiCatalogModel(config, models, options);
32
+ return {
33
+ models: explicitModel === undefined
34
+ ? models.map((model) => openAiCatalogModel(config, model, options))
35
+ : [explicitModel],
36
+ warnings: []
37
+ };
38
+ }
39
+ catch (error) {
40
+ if (explicitOpenAiProviderSelected(options, config.id)) {
41
+ throw error;
42
+ }
43
+ return { models: [], warnings: [`${config.name} is not responding at ${config.baseUrl}`] };
44
+ }
45
+ }
46
+ function openAiCatalogModel(config, model, options) {
47
+ const baseUrl = config.baseUrl ?? "";
48
+ return {
49
+ providerId: config.id,
50
+ providerName: config.name,
51
+ runtime: "openai-compatible",
52
+ baseUrl,
53
+ modelId: model.id,
54
+ aliases: [],
55
+ displayName: `${config.name} / ${model.id}`,
56
+ maxTokens: options.maxTokens,
57
+ ...externalReasoningConfig(model.id),
58
+ capabilities: ["text"],
59
+ availability: "loaded",
60
+ ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow })
61
+ };
62
+ }
63
+ function explicitOpenAiCatalogModel(config, models, options) {
64
+ const requested = options.model;
65
+ if (models.length !== 0 || requested === undefined || requested === "auto") {
66
+ return undefined;
67
+ }
68
+ if (!explicitOpenAiProviderSelected(options, config.id)) {
69
+ return undefined;
70
+ }
71
+ return openAiCatalogModel(config, { id: requested }, options);
72
+ }
73
+ function explicitOpenAiProviderSelected(options, providerId) {
74
+ return (options.provider === providerId ||
75
+ (options.provider === undefined &&
76
+ (options.runtime === "lmstudio" ||
77
+ options.runtime === "vllm" ||
78
+ options.runtime === "openai-compatible")));
79
+ }
80
+ async function discoverManagedLlamaProvider(config, options) {
81
+ const baseUrl = llamaBaseUrl(options);
82
+ const aliases = await listModelAliases();
83
+ const loaded = await loadedLlamaModels(config, options, baseUrl, aliases);
84
+ const startable = await startableLlamaModels(config, options, baseUrl, loaded.models, aliases);
85
+ return {
86
+ models: [...loaded.models, ...startable],
87
+ warnings: loaded.warnings
88
+ };
89
+ }
90
+ async function loadedLlamaModels(config, options, baseUrl, aliases) {
91
+ const models = await getLlamaServerModels(options);
92
+ if (models === undefined) {
93
+ return { models: [], warnings: [] };
94
+ }
95
+ const managed = await getManagedLlamaServerMetadata(options);
96
+ return {
97
+ models: models.map((model) => {
98
+ const contextWindow = model.contextWindow ?? (managed?.modelId === model.id ? managed.contextWindow : undefined);
99
+ return {
100
+ providerId: config.id,
101
+ providerName: config.name,
102
+ runtime: "managed-llama-server",
103
+ baseUrl,
104
+ modelId: model.id,
105
+ aliases: aliases.filter((alias) => alias.id === model.id).map((alias) => alias.name),
106
+ displayName: `${config.name} / ${model.id}`,
107
+ maxTokens: options.maxTokens,
108
+ reasoning: managedModelSupportsReasoning(model.id),
109
+ capabilities: ["text"],
110
+ availability: "loaded",
111
+ ...(contextWindow === undefined ? {} : { contextWindow })
112
+ };
113
+ }),
114
+ warnings: []
115
+ };
116
+ }
117
+ async function startableLlamaModels(config, options, baseUrl, loaded, aliases) {
118
+ const loadedIds = new Set(loaded.map((model) => model.modelId));
119
+ const startable = await Promise.all(aliases.map(async (alias) => {
120
+ if (loadedIds.has(alias.id)) {
121
+ return undefined;
122
+ }
123
+ try {
124
+ const resolved = await resolveLlamaModel(alias.name, options.chatTemplate);
125
+ return {
126
+ providerId: config.id,
127
+ providerName: config.name,
128
+ runtime: "managed-llama-server",
129
+ baseUrl,
130
+ modelId: resolved.id,
131
+ aliases: [alias.name],
132
+ displayName: `${config.name} / ${alias.name}`,
133
+ maxTokens: options.maxTokens,
134
+ reasoning: managedModelSupportsReasoning(resolved.id),
135
+ capabilities: ["text"],
136
+ availability: "startable",
137
+ ...(resolved.contextWindow === undefined ? {} : { contextWindow: resolved.contextWindow })
138
+ };
139
+ }
140
+ catch {
141
+ return undefined;
142
+ }
143
+ }));
144
+ return startable.filter((model) => model !== undefined);
145
+ }
146
+ function externalReasoningConfig(modelId) {
147
+ const normalized = modelId.toLowerCase();
148
+ if (isDeepSeekThinkingModel(normalized)) {
149
+ return { reasoning: true, thinkingFormat: "deepseek" };
150
+ }
151
+ if (isQwenThinkingModel(normalized)) {
152
+ return { reasoning: true, thinkingFormat: "qwen-chat-template" };
153
+ }
154
+ return {};
155
+ }
156
+ export function managedModelSupportsReasoning(modelId) {
157
+ const normalized = modelId.toLowerCase();
158
+ return (normalized.includes("reason") ||
159
+ normalized.includes("thinking") ||
160
+ isDeepSeekThinkingModel(normalized) ||
161
+ isQwenThinkingModel(normalized) ||
162
+ normalized.includes("gpt-oss") ||
163
+ normalized.includes("gemma-4"));
164
+ }
165
+ function isDeepSeekThinkingModel(normalizedModelId) {
166
+ return (normalizedModelId.includes("deepseek") &&
167
+ (hasModelToken(normalizedModelId, "r1") ||
168
+ hasModelToken(normalizedModelId, "v4") ||
169
+ hasModelToken(normalizedModelId, "4") ||
170
+ normalizedModelId.includes("reason") ||
171
+ normalizedModelId.includes("thinking")));
172
+ }
173
+ function isQwenThinkingModel(normalizedModelId) {
174
+ const qwenThinkingMarkers = [
175
+ "qwq",
176
+ "qwen3",
177
+ "qwen-3",
178
+ "qwen_3",
179
+ "qwen 3",
180
+ "qwen4",
181
+ "qwen-4",
182
+ "qwen_4",
183
+ "qwen 4"
184
+ ];
185
+ return (qwenThinkingMarkers.some((marker) => normalizedModelId.includes(marker)) ||
186
+ (normalizedModelId.includes("qwen") &&
187
+ (normalizedModelId.includes("reason") || normalizedModelId.includes("thinking"))));
188
+ }
189
+ function hasModelToken(normalizedModelId, token) {
190
+ return normalizedModelId.split(/[^a-z0-9]+/u).includes(token);
191
+ }