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 +21 -0
- package/README.md +253 -0
- package/dist/src/cli/cli.js +57 -0
- package/dist/src/cli/main.js +12 -0
- package/dist/src/common/json.js +21 -0
- package/dist/src/common/result.js +9 -0
- package/dist/src/llm/openai.js +78 -0
- package/dist/src/llm/types.js +1 -0
- package/dist/src/localpi/catalog.js +191 -0
- package/dist/src/localpi/llama-server.js +505 -0
- package/dist/src/localpi/managed-runtime.js +225 -0
- package/dist/src/localpi/models.js +169 -0
- package/dist/src/localpi/options.js +240 -0
- package/dist/src/localpi/provider-registry.js +121 -0
- package/dist/src/localpi/runtime-connection.js +75 -0
- package/dist/src/localpi/runtime-selection.js +75 -0
- package/dist/src/localpi/runtime-types.js +1 -0
- package/dist/src/localpi/runtime.js +89 -0
- package/dist/src/pi/config.js +108 -0
- package/dist/src/pi/extensions.js +348 -0
- package/dist/src/pi/launch.js +64 -0
- package/docs/2026-06-15-model-catalog-implementation-plan.md +220 -0
- package/docs/2026-06-16-startup-model-and-thinking-control-plan.md +129 -0
- package/docs/implementation-plan.md +75 -0
- package/docs/runtime-specification.md +148 -0
- package/docs/structured-output.md +9 -0
- package/package.json +54 -0
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
|
+
}
|