pi-llama-cpp 0.12.0 → 0.13.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 +13 -1
- package/package.json +2 -2
- package/src/constants.ts +8 -0
- package/src/interfaces/settings.ts +2 -1
- package/src/managers/command.ts +2 -0
- package/src/managers/server.ts +2 -2
- package/src/server.ts +24 -19
- package/src/ui/dialog.ts +6 -3
- package/src/ui/overrideSettingsList.ts +52 -24
- package/src/ui/serverListEditor.ts +27 -0
- package/src/ui/serverSettingsList.ts +65 -18
- package/src/utils/health.ts +48 -0
- package/tests/commandManager.test.ts +26 -0
- package/tests/dialog.test.ts +42 -0
- package/tests/health.test.ts +116 -0
- package/tests/overrides.test.ts +32 -0
- package/tests/server.test.ts +47 -4
- package/tests/serverManager.test.ts +4 -4
package/README.md
CHANGED
|
@@ -32,6 +32,16 @@ A [Pi Coding Agent](https://pi.dev/) extension that integrates with running [lla
|
|
|
32
32
|
|
|
33
33
|
> **Note:** You can run your server with API authentication with `llama-server --api-key <your key> ...`.
|
|
34
34
|
|
|
35
|
+
### Server Health Indicators
|
|
36
|
+
|
|
37
|
+
When browsing servers via `/models servers`, each server URL is prefixed with a health indicator:
|
|
38
|
+
|
|
39
|
+
| Icon | Status | Description |
|
|
40
|
+
| ---- | ----------- | --------------------------------------------- |
|
|
41
|
+
| 🟢 | Healthy | Server responded successfully to health check |
|
|
42
|
+
| 🟡 | Timeout | Server health check timed out |
|
|
43
|
+
| 🔴 | Unreachable | Server could not be reached |
|
|
44
|
+
|
|
35
45
|
## Installation
|
|
36
46
|
|
|
37
47
|
This package is a Pi extension. Install it with
|
|
@@ -128,7 +138,9 @@ Run `/models settings` to edit the scalar settings above without hand-editing JS
|
|
|
128
138
|
#### Server list editor
|
|
129
139
|
|
|
130
140
|
Run `/models servers` to add, edit or remove entries of `llamaSettings.servers`
|
|
131
|
-
without hand-editing JSON. Each
|
|
141
|
+
without hand-editing JSON. Each server URL is prefixed with a health indicator
|
|
142
|
+
(🟢 healthy, 🟡 timeout, 🔴 unreachable) that reflects the result of a
|
|
143
|
+
health check against the server. Each change is written immediately to the **project**
|
|
132
144
|
`.pi/settings.json` if it exists, otherwise to **global**
|
|
133
145
|
`~/.pi/agent/settings.json`.
|
|
134
146
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-llama-cpp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Pi extension for llama.cpp integration. Supports router, single and legacy models. Supports multiple servers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -40,6 +40,6 @@
|
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/node": "^26.4.1",
|
|
42
42
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
43
|
-
"vitest": "^5.0.
|
|
43
|
+
"vitest": "^5.0.1"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/constants.ts
CHANGED
|
@@ -28,6 +28,14 @@ export const API_KEY_PLACEHOLDER = "sk-placeholder";
|
|
|
28
28
|
*/
|
|
29
29
|
export const LLAMA_SERVER_URL = "http://127.0.0.1:8080";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Endpoint prefix of the OpenAI-compatible API exposed by llama-server.
|
|
33
|
+
* Native routes (/health, /props, /models/sse, ...) live directly under the
|
|
34
|
+
* server root; only OpenAI-dialect consumers (the Pi provider registration
|
|
35
|
+
* and the /models listing) address the API under this prefix.
|
|
36
|
+
*/
|
|
37
|
+
export const ENDPOINT_PREFIX = "/v1";
|
|
38
|
+
|
|
31
39
|
/**
|
|
32
40
|
* The default context if the server didn't expose it
|
|
33
41
|
*/
|
|
@@ -43,7 +43,8 @@ export interface ModelOverride {
|
|
|
43
43
|
*/
|
|
44
44
|
export interface LlamaServer {
|
|
45
45
|
/**
|
|
46
|
-
* The URL of the llama.cpp server.
|
|
46
|
+
* The URL of the llama.cpp server. Must be the bare origin — the
|
|
47
|
+
* OpenAI-compatible API is assumed under `/v1` (see {@link ENDPOINT_PREFIX}).
|
|
47
48
|
*/
|
|
48
49
|
url: string;
|
|
49
50
|
/**
|
package/src/managers/command.ts
CHANGED
|
@@ -318,6 +318,7 @@ export class CommandManager {
|
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
const servers = await this.settings.getLlamaServers();
|
|
321
|
+
const { serverTimeout } = await this.settings.resolveTimeouts();
|
|
321
322
|
|
|
322
323
|
await ctx.ui.custom<void>(
|
|
323
324
|
(tui, theme, keybindings, done) =>
|
|
@@ -333,6 +334,7 @@ export class CommandManager {
|
|
|
333
334
|
this.serverManager.update(pi);
|
|
334
335
|
},
|
|
335
336
|
onError: (message) => ctx.ui.notify(message, "error"),
|
|
337
|
+
serverTimeout,
|
|
336
338
|
}),
|
|
337
339
|
);
|
|
338
340
|
}
|
package/src/managers/server.ts
CHANGED
|
@@ -133,7 +133,7 @@ export class ServerManager {
|
|
|
133
133
|
* @param server The server
|
|
134
134
|
*/
|
|
135
135
|
private async registerProvider(server: Server, pi: ExtensionAPI) {
|
|
136
|
-
const {
|
|
136
|
+
const { apiBaseUrl, models, providerId, providerName } = server;
|
|
137
137
|
const apiKey = server.getApiKey();
|
|
138
138
|
const modelConfigs = await Promise.all(
|
|
139
139
|
models.map((m) => m.toProviderConfig()),
|
|
@@ -141,7 +141,7 @@ export class ServerManager {
|
|
|
141
141
|
|
|
142
142
|
pi.registerProvider(providerId, {
|
|
143
143
|
name: providerName,
|
|
144
|
-
baseUrl:
|
|
144
|
+
baseUrl: apiBaseUrl,
|
|
145
145
|
api: API_TYPE,
|
|
146
146
|
apiKey: apiKey,
|
|
147
147
|
models: modelConfigs,
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ApiClient } from "./api/client";
|
|
2
2
|
import {
|
|
3
3
|
API_KEY_PLACEHOLDER,
|
|
4
|
+
ENDPOINT_PREFIX,
|
|
4
5
|
PROVIDER_NAME,
|
|
5
6
|
PROVIDER_PREFIX,
|
|
6
7
|
} from "./constants";
|
|
@@ -20,6 +21,7 @@ import { LegacyModel } from "./models/legacyModel";
|
|
|
20
21
|
import { RouterModel } from "./models/routerModel";
|
|
21
22
|
import { SingleModel } from "./models/singleModel";
|
|
22
23
|
import { SSEManager } from "./sse/manager";
|
|
24
|
+
import { checkServerHealth } from "./utils/health";
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Optional constructor collaborators for {@link Server} — the seam tests use
|
|
@@ -59,6 +61,19 @@ export class Server {
|
|
|
59
61
|
return this.options.baseUrl;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Base URL of the OpenAI-compatible API: {@link baseUrl} joined with
|
|
66
|
+
* {@link ENDPOINT_PREFIX}. Idempotent — a baseUrl that already ends with
|
|
67
|
+
* the prefix is returned untouched, so proxied setups exposing the API
|
|
68
|
+
* under a /v1 path don't double up.
|
|
69
|
+
*/
|
|
70
|
+
get apiBaseUrl(): string {
|
|
71
|
+
const { baseUrl } = this.options;
|
|
72
|
+
return baseUrl.endsWith(ENDPOINT_PREFIX)
|
|
73
|
+
? baseUrl
|
|
74
|
+
: `${baseUrl}${ENDPOINT_PREFIX}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
/**
|
|
63
78
|
* Maximum time (ms) for server verification and SSE support probe.
|
|
64
79
|
* Resolved live from the injected settings manager.
|
|
@@ -163,28 +178,16 @@ export class Server {
|
|
|
163
178
|
/**
|
|
164
179
|
* Checks if the server is ready, with a timeout.
|
|
165
180
|
*
|
|
181
|
+
* Delegates to the shared health probe (`utils/health`) — bypasses the
|
|
182
|
+
* `ApiClient` on purpose: each caller probes once per scan, so the
|
|
183
|
+
* client's cache/dedup would be dead weight, and a plain `fetch` with
|
|
184
|
+
* `AbortSignal.timeout` actually cancels the request on timeout.
|
|
185
|
+
*
|
|
166
186
|
* @param timeout Maximum time to wait for the health check
|
|
167
187
|
* @returns The server status
|
|
168
188
|
*/
|
|
169
189
|
async isReady(timeout: number): Promise<ServerStatus> {
|
|
170
|
-
|
|
171
|
-
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
172
|
-
setTimeout(() => reject(new Error("timeout")), timeout),
|
|
173
|
-
);
|
|
174
|
-
const health = await Promise.race([
|
|
175
|
-
this.fetchServerHealth(),
|
|
176
|
-
timeoutPromise,
|
|
177
|
-
]);
|
|
178
|
-
if (health.status === "ok") {
|
|
179
|
-
return ServerStatus.READY;
|
|
180
|
-
}
|
|
181
|
-
return ServerStatus.UNREACHABLE;
|
|
182
|
-
} catch (error) {
|
|
183
|
-
if (error instanceof Error && error.message === "timeout") {
|
|
184
|
-
return ServerStatus.TIMEOUT;
|
|
185
|
-
}
|
|
186
|
-
return ServerStatus.UNREACHABLE;
|
|
187
|
-
}
|
|
190
|
+
return checkServerHealth(this.baseUrl, timeout, this.getApiKey());
|
|
188
191
|
}
|
|
189
192
|
|
|
190
193
|
/**
|
|
@@ -202,7 +205,9 @@ export class Server {
|
|
|
202
205
|
* @return The models from the server
|
|
203
206
|
*/
|
|
204
207
|
async fetchModels(): Promise<ModelsEndpoint> {
|
|
205
|
-
return await this.apiClient.get<ModelsEndpoint>(
|
|
208
|
+
return await this.apiClient.get<ModelsEndpoint>(
|
|
209
|
+
`${ENDPOINT_PREFIX}/models`,
|
|
210
|
+
);
|
|
206
211
|
}
|
|
207
212
|
|
|
208
213
|
/**
|
package/src/ui/dialog.ts
CHANGED
|
@@ -167,6 +167,10 @@ export class InputDialog extends BaseDialog {
|
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
169
|
* Builds a `SettingItem.submenu` factory that opens an `InputDialog`.
|
|
170
|
+
* The dialog prefills from the `currentValue` the containing
|
|
171
|
+
* `SettingsList` passes at activation time — not a value captured at
|
|
172
|
+
* build time — so re-entering a field after a commit shows the
|
|
173
|
+
* just-saved value.
|
|
170
174
|
*/
|
|
171
175
|
static inputSubmenu =
|
|
172
176
|
(
|
|
@@ -175,11 +179,10 @@ export class InputDialog extends BaseDialog {
|
|
|
175
179
|
title: string,
|
|
176
180
|
message: string,
|
|
177
181
|
placeholder: string | undefined,
|
|
178
|
-
initialValue: string,
|
|
179
182
|
validate?: (raw: string) => string | null,
|
|
180
183
|
) =>
|
|
181
184
|
(
|
|
182
|
-
|
|
185
|
+
currentValue: string,
|
|
183
186
|
done: (selectedValue?: string) => void,
|
|
184
187
|
): Component => {
|
|
185
188
|
const dialog = new InputDialog({
|
|
@@ -188,7 +191,7 @@ export class InputDialog extends BaseDialog {
|
|
|
188
191
|
title,
|
|
189
192
|
message,
|
|
190
193
|
placeholder,
|
|
191
|
-
initialValue,
|
|
194
|
+
initialValue: currentValue,
|
|
192
195
|
validate,
|
|
193
196
|
onSubmit: (value) => done(value),
|
|
194
197
|
onCancel: () => done(undefined),
|
|
@@ -62,7 +62,34 @@ export interface OverrideSettingsListOptions {
|
|
|
62
62
|
* `onChange` (both finite cycling and `done(value)` from the input
|
|
63
63
|
* submenus arrive there), so no per-field callback is needed here.
|
|
64
64
|
*/
|
|
65
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Canonical display label for one of an override's editable fields —
|
|
67
|
+
* the single source of truth for both the field rows' `currentValue`
|
|
68
|
+
* and the post-commit refresh of an open field submenu, so the two
|
|
69
|
+
* can't drift.
|
|
70
|
+
*/
|
|
71
|
+
export const overrideFieldValue = (
|
|
72
|
+
field: string,
|
|
73
|
+
override: ModelOverride,
|
|
74
|
+
): string => {
|
|
75
|
+
if (field.startsWith("cost.")) {
|
|
76
|
+
const costField = field.split(".")[1] as keyof ModelCostRates;
|
|
77
|
+
return String(override.cost?.[costField] ?? 0);
|
|
78
|
+
}
|
|
79
|
+
if (field === "capabilities")
|
|
80
|
+
return override.capabilities?.join(" | ") ?? "text";
|
|
81
|
+
if (field === "reasoning")
|
|
82
|
+
return override.reasoning === undefined
|
|
83
|
+
? "true"
|
|
84
|
+
: override.reasoning
|
|
85
|
+
? "true"
|
|
86
|
+
: "false";
|
|
87
|
+
if (field === "maxTokens") return String(override.maxTokens ?? 0);
|
|
88
|
+
if (field === "contextSize") return String(override.contextSize ?? 0);
|
|
89
|
+
return "";
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const buildOverrideFieldItems = (
|
|
66
93
|
entry: { pattern: string; override: ModelOverride },
|
|
67
94
|
theme: Theme,
|
|
68
95
|
tui: TUI,
|
|
@@ -79,7 +106,6 @@ const buildOverrideFieldItems = (
|
|
|
79
106
|
TITLES.edit(FIELDS.pattern.label),
|
|
80
107
|
fieldMessage(FIELDS.pattern),
|
|
81
108
|
FIELDS.pattern.placeholder,
|
|
82
|
-
entry.pattern,
|
|
83
109
|
(raw) => {
|
|
84
110
|
const trimmed = raw.trim();
|
|
85
111
|
return trimmed.length > 0 ? trimmed : null;
|
|
@@ -91,14 +117,13 @@ const buildOverrideFieldItems = (
|
|
|
91
117
|
id: "cost.input",
|
|
92
118
|
label: FIELDS.inputCost.label,
|
|
93
119
|
description: FIELDS.inputCost.description,
|
|
94
|
-
currentValue:
|
|
120
|
+
currentValue: overrideFieldValue("cost.input", entry.override),
|
|
95
121
|
submenu: InputDialog.inputSubmenu(
|
|
96
122
|
theme,
|
|
97
123
|
tui,
|
|
98
124
|
TITLES.edit(FIELDS.inputCost.label),
|
|
99
125
|
fieldMessage(FIELDS.inputCost),
|
|
100
126
|
FIELDS.inputCost.placeholder,
|
|
101
|
-
String(entry.override.cost?.input ?? 0),
|
|
102
127
|
(raw) => {
|
|
103
128
|
const parsed = parseCostValue(raw);
|
|
104
129
|
return parsed === null ? null : String(parsed);
|
|
@@ -110,14 +135,13 @@ const buildOverrideFieldItems = (
|
|
|
110
135
|
id: "cost.output",
|
|
111
136
|
label: FIELDS.outputCost.label,
|
|
112
137
|
description: FIELDS.outputCost.description,
|
|
113
|
-
currentValue:
|
|
138
|
+
currentValue: overrideFieldValue("cost.output", entry.override),
|
|
114
139
|
submenu: InputDialog.inputSubmenu(
|
|
115
140
|
theme,
|
|
116
141
|
tui,
|
|
117
142
|
TITLES.edit(FIELDS.outputCost.label),
|
|
118
143
|
fieldMessage(FIELDS.outputCost),
|
|
119
144
|
FIELDS.outputCost.placeholder,
|
|
120
|
-
String(entry.override.cost?.output ?? 0),
|
|
121
145
|
(raw) => {
|
|
122
146
|
const parsed = parseCostValue(raw);
|
|
123
147
|
return parsed === null ? null : String(parsed);
|
|
@@ -129,14 +153,13 @@ const buildOverrideFieldItems = (
|
|
|
129
153
|
id: "cost.cacheRead",
|
|
130
154
|
label: FIELDS.cacheReadCost.label,
|
|
131
155
|
description: FIELDS.cacheReadCost.description,
|
|
132
|
-
currentValue:
|
|
156
|
+
currentValue: overrideFieldValue("cost.cacheRead", entry.override),
|
|
133
157
|
submenu: InputDialog.inputSubmenu(
|
|
134
158
|
theme,
|
|
135
159
|
tui,
|
|
136
160
|
TITLES.edit(FIELDS.cacheReadCost.label),
|
|
137
161
|
fieldMessage(FIELDS.cacheReadCost),
|
|
138
162
|
FIELDS.cacheReadCost.placeholder,
|
|
139
|
-
String(entry.override.cost?.cacheRead ?? 0),
|
|
140
163
|
(raw) => {
|
|
141
164
|
const parsed = parseCostValue(raw);
|
|
142
165
|
return parsed === null ? null : String(parsed);
|
|
@@ -148,14 +171,13 @@ const buildOverrideFieldItems = (
|
|
|
148
171
|
id: "cost.cacheWrite",
|
|
149
172
|
label: FIELDS.cacheWriteCost.label,
|
|
150
173
|
description: FIELDS.cacheWriteCost.description,
|
|
151
|
-
currentValue:
|
|
174
|
+
currentValue: overrideFieldValue("cost.cacheWrite", entry.override),
|
|
152
175
|
submenu: InputDialog.inputSubmenu(
|
|
153
176
|
theme,
|
|
154
177
|
tui,
|
|
155
178
|
TITLES.edit(FIELDS.cacheWriteCost.label),
|
|
156
179
|
fieldMessage(FIELDS.cacheWriteCost),
|
|
157
180
|
FIELDS.cacheWriteCost.placeholder,
|
|
158
|
-
String(entry.override.cost?.cacheWrite ?? 0),
|
|
159
181
|
(raw) => {
|
|
160
182
|
const parsed = parseCostValue(raw);
|
|
161
183
|
return parsed === null ? null : String(parsed);
|
|
@@ -167,7 +189,7 @@ const buildOverrideFieldItems = (
|
|
|
167
189
|
id: "capabilities",
|
|
168
190
|
label: FIELDS.capabilities.label,
|
|
169
191
|
description: FIELDS.capabilities.description,
|
|
170
|
-
currentValue: entry.override
|
|
192
|
+
currentValue: overrideFieldValue("capabilities", entry.override),
|
|
171
193
|
values: ["text", "text | image"],
|
|
172
194
|
},
|
|
173
195
|
// Reasoning — finite: true or false
|
|
@@ -175,12 +197,7 @@ const buildOverrideFieldItems = (
|
|
|
175
197
|
id: "reasoning",
|
|
176
198
|
label: FIELDS.reasoning.label,
|
|
177
199
|
description: FIELDS.reasoning.description,
|
|
178
|
-
currentValue:
|
|
179
|
-
entry.override.reasoning === undefined
|
|
180
|
-
? "true"
|
|
181
|
-
: entry.override.reasoning
|
|
182
|
-
? "true"
|
|
183
|
-
: "false",
|
|
200
|
+
currentValue: overrideFieldValue("reasoning", entry.override),
|
|
184
201
|
values: ["true", "false"],
|
|
185
202
|
},
|
|
186
203
|
// Context size — infinite
|
|
@@ -188,14 +205,13 @@ const buildOverrideFieldItems = (
|
|
|
188
205
|
id: "contextSize",
|
|
189
206
|
label: FIELDS.contextSize.label,
|
|
190
207
|
description: FIELDS.contextSize.description,
|
|
191
|
-
currentValue:
|
|
208
|
+
currentValue: overrideFieldValue("contextSize", entry.override),
|
|
192
209
|
submenu: InputDialog.inputSubmenu(
|
|
193
210
|
theme,
|
|
194
211
|
tui,
|
|
195
212
|
TITLES.edit(FIELDS.contextSize.label),
|
|
196
213
|
fieldMessage(FIELDS.contextSize),
|
|
197
214
|
FIELDS.contextSize.placeholder,
|
|
198
|
-
String(entry.override.contextSize ?? 0),
|
|
199
215
|
(raw) => {
|
|
200
216
|
const n = Number(raw.trim());
|
|
201
217
|
return n >= 0 && isFinite(n) ? String(n) : null;
|
|
@@ -207,14 +223,13 @@ const buildOverrideFieldItems = (
|
|
|
207
223
|
id: "maxTokens",
|
|
208
224
|
label: FIELDS.maxTokens.label,
|
|
209
225
|
description: FIELDS.maxTokens.description,
|
|
210
|
-
currentValue:
|
|
226
|
+
currentValue: overrideFieldValue("maxTokens", entry.override),
|
|
211
227
|
submenu: InputDialog.inputSubmenu(
|
|
212
228
|
theme,
|
|
213
229
|
tui,
|
|
214
230
|
TITLES.edit(FIELDS.maxTokens.label),
|
|
215
231
|
fieldMessage(FIELDS.maxTokens),
|
|
216
232
|
FIELDS.maxTokens.placeholder,
|
|
217
|
-
String(entry.override.maxTokens ?? 0),
|
|
218
233
|
(raw) => {
|
|
219
234
|
const n = Number(raw.trim());
|
|
220
235
|
return n >= 0 && isFinite(n) ? String(n) : null;
|
|
@@ -256,6 +271,8 @@ class OverrideEntryListEditor implements Component, Focusable {
|
|
|
256
271
|
private isFocused = false;
|
|
257
272
|
private _selectedIndex = 0;
|
|
258
273
|
private submenuOpen = false;
|
|
274
|
+
/** Currently open field submenu, so commits can refresh its rows */
|
|
275
|
+
private fieldList: SettingsList | null = null;
|
|
259
276
|
|
|
260
277
|
constructor(
|
|
261
278
|
/** Shared options object — `persistSnapshot` updates `options.servers`
|
|
@@ -389,8 +406,7 @@ class OverrideEntryListEditor implements Component, Focusable {
|
|
|
389
406
|
// so `i` is stable for this list instance's lifetime)
|
|
390
407
|
const current = this.getEntries()[i] ?? entry;
|
|
391
408
|
const fieldItems = buildOverrideFieldItems(current, theme, tui);
|
|
392
|
-
|
|
393
|
-
return new SettingsList(
|
|
409
|
+
const fieldList = new SettingsList(
|
|
394
410
|
fieldItems,
|
|
395
411
|
Math.min(fieldItems.length + 2, 15),
|
|
396
412
|
getSettingsListTheme(),
|
|
@@ -399,9 +415,15 @@ class OverrideEntryListEditor implements Component, Focusable {
|
|
|
399
415
|
(field, value) => void this.handleFieldChange(i, field, value),
|
|
400
416
|
() => {
|
|
401
417
|
this.submenuOpen = false;
|
|
418
|
+
this.fieldList = null;
|
|
402
419
|
done();
|
|
403
420
|
},
|
|
404
421
|
);
|
|
422
|
+
// Kept so handleFieldChange can refresh the open submenu's rows
|
|
423
|
+
// after a commit (the submenu stays open across edits)
|
|
424
|
+
this.fieldList = fieldList;
|
|
425
|
+
this.submenuOpen = true;
|
|
426
|
+
return fieldList;
|
|
405
427
|
},
|
|
406
428
|
}));
|
|
407
429
|
|
|
@@ -622,12 +644,18 @@ class OverrideEntryListEditor implements Component, Focusable {
|
|
|
622
644
|
entry.pattern,
|
|
623
645
|
updatedOverride,
|
|
624
646
|
);
|
|
625
|
-
// Keep the field submenu open —
|
|
647
|
+
// Keep the field submenu open — refresh the entry's summary row and
|
|
648
|
+
// the open submenu's own row so re-entering the field prefills the
|
|
649
|
+
// value just saved (InputDialog prefills from the row's currentValue)
|
|
626
650
|
await this.persistSnapshot(next, () => {
|
|
627
651
|
this.settingsList?.updateValue(
|
|
628
652
|
`entry-${entryIndex}`,
|
|
629
653
|
formatOverrideSummary(updatedOverride),
|
|
630
654
|
);
|
|
655
|
+
this.fieldList?.updateValue(
|
|
656
|
+
field,
|
|
657
|
+
overrideFieldValue(field, updatedOverride),
|
|
658
|
+
);
|
|
631
659
|
this.options.tui.requestRender();
|
|
632
660
|
});
|
|
633
661
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { PROVIDER_PREFIX } from "../constants";
|
|
2
|
+
import { ServerStatus } from "../enums/serverStatus";
|
|
2
3
|
import type { LlamaServer } from "../interfaces/settings";
|
|
4
|
+
import { checkServerHealth } from "../utils/health";
|
|
3
5
|
import { normalizeUrl } from "../utils/urls";
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -30,3 +32,28 @@ export const formatServerSuffix = (server: LlamaServer): string => {
|
|
|
30
32
|
const id = server.id ?? `${PROVIDER_PREFIX}=${server.url}`;
|
|
31
33
|
return server.name ? `(${id} - ${server.name})` : `(${server.id})`;
|
|
32
34
|
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Emoji indicators for server health status.
|
|
38
|
+
*/
|
|
39
|
+
const SERVER_STATUS_ICONS: Record<ServerStatus, string> = {
|
|
40
|
+
[ServerStatus.READY]: "🟢",
|
|
41
|
+
[ServerStatus.TIMEOUT]: "🟡",
|
|
42
|
+
[ServerStatus.UNREACHABLE]: "🔴",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Checks the health of a server and returns the corresponding emoji.
|
|
47
|
+
*
|
|
48
|
+
* Delegates the probe/classification to the shared `checkServerHealth`
|
|
49
|
+
* (`utils/health`) — this wrapper only maps the status to its icon.
|
|
50
|
+
*
|
|
51
|
+
* @param url - The server URL to check
|
|
52
|
+
* @param timeout - Maximum time (ms) to wait for the health check
|
|
53
|
+
* @returns The health emoji for the server status
|
|
54
|
+
*/
|
|
55
|
+
export const getServerHealthEmoji = async (
|
|
56
|
+
url: string,
|
|
57
|
+
timeout: number,
|
|
58
|
+
): Promise<string> =>
|
|
59
|
+
SERVER_STATUS_ICONS[await checkServerHealth(url, timeout)];
|
|
@@ -11,7 +11,11 @@ import type {
|
|
|
11
11
|
import { SettingsList, type SettingItem } from "@earendil-works/pi-tui";
|
|
12
12
|
import type { LlamaServer } from "../interfaces/settings";
|
|
13
13
|
import { ConfirmDialog, InputDialog } from "./dialog";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
formatServerSuffix,
|
|
16
|
+
getServerHealthEmoji,
|
|
17
|
+
normalizeServerUrl,
|
|
18
|
+
} from "./serverListEditor";
|
|
15
19
|
import { fieldMessage, FIELDS, HINTS, TITLES } from "./strings";
|
|
16
20
|
|
|
17
21
|
/**
|
|
@@ -36,6 +40,8 @@ export interface ServerSettingsListOptions {
|
|
|
36
40
|
done: () => void;
|
|
37
41
|
/** Notifies about persistence errors */
|
|
38
42
|
onError: (message: string) => void;
|
|
43
|
+
/** Timeout (ms) for health checks. Defaults to 1000ms if not provided. */
|
|
44
|
+
serverTimeout?: number;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
/**
|
|
@@ -59,7 +65,6 @@ const buildServerFieldItems = (
|
|
|
59
65
|
TITLES.edit(FIELDS.serverUrl.label),
|
|
60
66
|
fieldMessage(FIELDS.serverUrl),
|
|
61
67
|
FIELDS.serverUrl.placeholder,
|
|
62
|
-
server.url,
|
|
63
68
|
normalizeServerUrl,
|
|
64
69
|
),
|
|
65
70
|
},
|
|
@@ -74,7 +79,6 @@ const buildServerFieldItems = (
|
|
|
74
79
|
TITLES.edit(FIELDS.providerId.label),
|
|
75
80
|
fieldMessage(FIELDS.providerId),
|
|
76
81
|
FIELDS.providerId.placeholder,
|
|
77
|
-
server.id ?? "",
|
|
78
82
|
),
|
|
79
83
|
},
|
|
80
84
|
{
|
|
@@ -88,7 +92,6 @@ const buildServerFieldItems = (
|
|
|
88
92
|
TITLES.edit(FIELDS.displayName.label),
|
|
89
93
|
fieldMessage(FIELDS.displayName),
|
|
90
94
|
FIELDS.displayName.placeholder,
|
|
91
|
-
server.name ?? "",
|
|
92
95
|
),
|
|
93
96
|
},
|
|
94
97
|
];
|
|
@@ -103,21 +106,27 @@ const buildServerRow = (
|
|
|
103
106
|
theme: Theme,
|
|
104
107
|
tui: TUI,
|
|
105
108
|
onChange: (field: string, value: string) => void,
|
|
109
|
+
healthEmoji: string,
|
|
110
|
+
onSubmenuChange: (open: boolean) => void,
|
|
106
111
|
): SettingItem => ({
|
|
107
112
|
id: `server-${index}`,
|
|
108
|
-
label: server.url
|
|
113
|
+
label: `${healthEmoji} ${server.url}`,
|
|
109
114
|
description: HINTS.serverRow,
|
|
110
115
|
currentValue: formatServerSuffix(server),
|
|
111
116
|
submenu: (_cv, done) => {
|
|
112
117
|
// Rebuild the field items on open so they prefill with current values
|
|
113
118
|
const items = buildServerFieldItems(server, theme, tui);
|
|
119
|
+
onSubmenuChange(true);
|
|
114
120
|
return new SettingsList(
|
|
115
121
|
items,
|
|
116
122
|
Math.min(items.length + 2, 15),
|
|
117
123
|
getSettingsListTheme(),
|
|
118
124
|
// Field commits arrive here: done(value) from the input submenus
|
|
119
125
|
(field, value) => onChange(field, value),
|
|
120
|
-
|
|
126
|
+
() => {
|
|
127
|
+
onSubmenuChange(false);
|
|
128
|
+
done();
|
|
129
|
+
},
|
|
121
130
|
);
|
|
122
131
|
},
|
|
123
132
|
});
|
|
@@ -135,7 +144,7 @@ const buildServerRow = (
|
|
|
135
144
|
* - **Esc** closes the editor.
|
|
136
145
|
*/
|
|
137
146
|
export class ServerSettingsList implements Component, Focusable {
|
|
138
|
-
private settingsList
|
|
147
|
+
private settingsList!: SettingsList;
|
|
139
148
|
private mode: "list" | "wizard" | "confirm" = "list";
|
|
140
149
|
private wizardStep = 0;
|
|
141
150
|
private wizardUrl = "";
|
|
@@ -143,9 +152,14 @@ export class ServerSettingsList implements Component, Focusable {
|
|
|
143
152
|
private wizardDialog: InputDialog | undefined;
|
|
144
153
|
private confirmDialog: ConfirmDialog | undefined;
|
|
145
154
|
private isFocused = false;
|
|
155
|
+
/** True while a server row's field-edit submenu is open */
|
|
156
|
+
private submenuOpen = false;
|
|
146
157
|
|
|
147
158
|
constructor(private readonly options: ServerSettingsListOptions) {
|
|
148
|
-
this.
|
|
159
|
+
this.buildSettingsList().then((list) => {
|
|
160
|
+
this.settingsList = list;
|
|
161
|
+
this.options.tui.requestRender();
|
|
162
|
+
});
|
|
149
163
|
}
|
|
150
164
|
|
|
151
165
|
/**
|
|
@@ -154,11 +168,30 @@ export class ServerSettingsList implements Component, Focusable {
|
|
|
154
168
|
* successful persist so added/deleted/edited servers are reflected
|
|
155
169
|
* immediately (the initial snapshot is not mutated by `persist`).
|
|
156
170
|
*/
|
|
157
|
-
private buildSettingsList(): SettingsList {
|
|
171
|
+
private async buildSettingsList(): Promise<SettingsList> {
|
|
158
172
|
const { theme, tui } = this.options;
|
|
173
|
+
const serverTimeout = this.options.serverTimeout ?? 1000;
|
|
174
|
+
// Check health for each server in parallel
|
|
175
|
+
const healthEmojis = await Promise.all(
|
|
176
|
+
this.options.servers.map((server) =>
|
|
177
|
+
getServerHealthEmoji(server.url, serverTimeout),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// A fresh list never has a submenu open (also covers the rebuild
|
|
182
|
+
// paths, which discard any open field submenu)
|
|
183
|
+
this.submenuOpen = false;
|
|
159
184
|
const items: SettingItem[] = this.options.servers.map((server, i) =>
|
|
160
|
-
buildServerRow(
|
|
161
|
-
|
|
185
|
+
buildServerRow(
|
|
186
|
+
server,
|
|
187
|
+
i,
|
|
188
|
+
theme,
|
|
189
|
+
tui,
|
|
190
|
+
(field, value) => this.handleFieldChange(field, value),
|
|
191
|
+
healthEmojis[i],
|
|
192
|
+
(open) => {
|
|
193
|
+
this.submenuOpen = open;
|
|
194
|
+
},
|
|
162
195
|
),
|
|
163
196
|
);
|
|
164
197
|
|
|
@@ -190,7 +223,7 @@ export class ServerSettingsList implements Component, Focusable {
|
|
|
190
223
|
// -- Component -----------------------------------------------------------
|
|
191
224
|
|
|
192
225
|
invalidate(): void {
|
|
193
|
-
this.settingsList
|
|
226
|
+
this.settingsList?.invalidate();
|
|
194
227
|
this.wizardDialog?.invalidate();
|
|
195
228
|
this.confirmDialog?.invalidate();
|
|
196
229
|
}
|
|
@@ -206,6 +239,15 @@ export class ServerSettingsList implements Component, Focusable {
|
|
|
206
239
|
return;
|
|
207
240
|
}
|
|
208
241
|
|
|
242
|
+
// A server row's field submenu is open: delegate everything (Esc,
|
|
243
|
+
// a/d, arrows) to the containing SettingsList, which forwards input
|
|
244
|
+
// to the submenu. Intercepting here would close the whole editor on
|
|
245
|
+
// Esc and trigger add/delete while typing in a field.
|
|
246
|
+
if (this.submenuOpen) {
|
|
247
|
+
this.settingsList?.handleInput(data);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
209
251
|
const kb = this.options.keybindings;
|
|
210
252
|
if (kb.matches(data, "tui.select.cancel")) {
|
|
211
253
|
this.options.done();
|
|
@@ -233,7 +275,7 @@ export class ServerSettingsList implements Component, Focusable {
|
|
|
233
275
|
return;
|
|
234
276
|
}
|
|
235
277
|
// Delegate to SettingsList (it handles up/down internally for rendering)
|
|
236
|
-
this.settingsList
|
|
278
|
+
this.settingsList?.handleInput(data);
|
|
237
279
|
}
|
|
238
280
|
|
|
239
281
|
render(width: number): string[] {
|
|
@@ -245,6 +287,10 @@ export class ServerSettingsList implements Component, Focusable {
|
|
|
245
287
|
return this.confirmDialog?.render(width) ?? [];
|
|
246
288
|
}
|
|
247
289
|
|
|
290
|
+
// settingsList may not be initialized yet if buildSettingsList is still
|
|
291
|
+
// resolving health checks (async in constructor). Return empty until ready.
|
|
292
|
+
if (!this.settingsList) return [];
|
|
293
|
+
|
|
248
294
|
// List mode: the shortcuts live in the rows' description; only when the
|
|
249
295
|
// list is empty (no rows → no description) show them as the hint line
|
|
250
296
|
const settingsLines = this.settingsList.render(width);
|
|
@@ -272,8 +318,9 @@ export class ServerSettingsList implements Component, Focusable {
|
|
|
272
318
|
* (clamped to the last row). A fresh SettingsList starts its cursor
|
|
273
319
|
* at index 0, so `selectItem` is used to restore the position.
|
|
274
320
|
*/
|
|
275
|
-
private rebuildList(targetIndex: number): void {
|
|
276
|
-
|
|
321
|
+
private async rebuildList(targetIndex: number): Promise<void> {
|
|
322
|
+
const list = await this.buildSettingsList();
|
|
323
|
+
this.settingsList = list;
|
|
277
324
|
const count = this.options.servers.length;
|
|
278
325
|
if (count === 0) {
|
|
279
326
|
this._selectedIndex = 0;
|
|
@@ -381,7 +428,7 @@ export class ServerSettingsList implements Component, Focusable {
|
|
|
381
428
|
// Adopt the persisted list and rebuild so the new row appears;
|
|
382
429
|
// the new server is appended last — put the cursor on it
|
|
383
430
|
this.options.servers = next;
|
|
384
|
-
this.rebuildList(next.length - 1);
|
|
431
|
+
await this.rebuildList(next.length - 1);
|
|
385
432
|
this.mode = "list";
|
|
386
433
|
} catch (err) {
|
|
387
434
|
this.options.onError(String(err));
|
|
@@ -426,7 +473,7 @@ export class ServerSettingsList implements Component, Focusable {
|
|
|
426
473
|
// the server that followed the deleted one now sits at the same
|
|
427
474
|
// index (rebuildList clamps when the last row was deleted)
|
|
428
475
|
this.options.servers = next;
|
|
429
|
-
this.rebuildList(idx);
|
|
476
|
+
await this.rebuildList(idx);
|
|
430
477
|
this.mode = "list";
|
|
431
478
|
} catch (err) {
|
|
432
479
|
this.options.onError(String(err));
|
|
@@ -456,7 +503,7 @@ export class ServerSettingsList implements Component, Focusable {
|
|
|
456
503
|
// URL/id/name changes alter row labels → rebuild the list;
|
|
457
504
|
// keep the cursor on the edited server
|
|
458
505
|
this.options.servers = next;
|
|
459
|
-
this.rebuildList(idx);
|
|
506
|
+
await this.rebuildList(idx);
|
|
460
507
|
this.options.tui.requestRender();
|
|
461
508
|
} catch (err) {
|
|
462
509
|
this.options.onError(String(err));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ServerStatus } from "../enums/serverStatus";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Probes a llama-server's `/health` endpoint and classifies the outcome.
|
|
5
|
+
*
|
|
6
|
+
* The single source of truth for health classification — both
|
|
7
|
+
* `Server.isReady` and the UI health indicators delegate here.
|
|
8
|
+
*
|
|
9
|
+
* - HTTP success with `{ status: "ok" }` → `READY`
|
|
10
|
+
* - The request is still in flight when the deadline passes
|
|
11
|
+
* (`AbortSignal.timeout` aborts it) → `TIMEOUT`
|
|
12
|
+
* - Anything else (network error, non-JSON or non-ok payload)
|
|
13
|
+
* → `UNREACHABLE`
|
|
14
|
+
*
|
|
15
|
+
* The HTTP status code itself is not inspected: a llama-server answers
|
|
16
|
+
* `/health` with the payload's `status` field, and both classification
|
|
17
|
+
* branches key off it (matching the behavior of `ApiClient.get`, which
|
|
18
|
+
* also ignores status codes and parses the body).
|
|
19
|
+
*
|
|
20
|
+
* @param url The bare origin of the llama-server (the endpoint is
|
|
21
|
+
* `<url>/health`, without the `/v1` prefix)
|
|
22
|
+
* @param timeout Maximum time (ms) to wait for the health check
|
|
23
|
+
* @param apiKey Optional API key, sent as a Bearer token — servers
|
|
24
|
+
* behind an API-key gate would otherwise report UNREACHABLE
|
|
25
|
+
* @returns The classified server status
|
|
26
|
+
*/
|
|
27
|
+
export const checkServerHealth = async (
|
|
28
|
+
url: string,
|
|
29
|
+
timeout: number,
|
|
30
|
+
apiKey?: string,
|
|
31
|
+
): Promise<ServerStatus> => {
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(`${url}/health`, {
|
|
34
|
+
signal: AbortSignal.timeout(timeout),
|
|
35
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
|
|
36
|
+
});
|
|
37
|
+
const data = (await response.json()) as { status?: string };
|
|
38
|
+
return data.status === "ok" ? ServerStatus.READY : ServerStatus.UNREACHABLE;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// `AbortSignal.timeout` rejects `fetch` with a `TimeoutError`
|
|
41
|
+
// DOMException (some runtimes surface it as `AbortError` with a
|
|
42
|
+
// timeout message).
|
|
43
|
+
const isTimeout =
|
|
44
|
+
error instanceof Error &&
|
|
45
|
+
(error.name === "TimeoutError" || error.name === "AbortError");
|
|
46
|
+
return isTimeout ? ServerStatus.TIMEOUT : ServerStatus.UNREACHABLE;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../src/managers/command";
|
|
12
12
|
import { ServerManager } from "../src/managers/server";
|
|
13
13
|
import type { LlamaSettingsManager } from "../src/managers/settings";
|
|
14
|
+
import * as serverListEditor from "../src/ui/serverListEditor";
|
|
14
15
|
import { ServerSettingsList } from "../src/ui/serverSettingsList";
|
|
15
16
|
import {
|
|
16
17
|
createMockCtx,
|
|
@@ -25,8 +26,27 @@ beforeEach(() => {
|
|
|
25
26
|
initTheme();
|
|
26
27
|
vi.clearAllMocks();
|
|
27
28
|
mockRpc.mockResolvedValue({ data: [] });
|
|
29
|
+
// Mock health checks to return "healthy" immediately
|
|
30
|
+
vi.spyOn(serverListEditor, "getServerHealthEmoji").mockResolvedValue("🟢");
|
|
28
31
|
});
|
|
29
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Waits for a ServerSettingsList to finish its async initialization
|
|
35
|
+
* (health checks + SettingsList construction).
|
|
36
|
+
*/
|
|
37
|
+
const waitForEditor = (editor: ServerSettingsList): Promise<void> =>
|
|
38
|
+
new Promise((resolve) => {
|
|
39
|
+
const check = () => {
|
|
40
|
+
// @ts-expect-error settingsList is private
|
|
41
|
+
if (editor.settingsList) {
|
|
42
|
+
resolve();
|
|
43
|
+
} else {
|
|
44
|
+
setTimeout(check, 10);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
check();
|
|
48
|
+
});
|
|
49
|
+
|
|
30
50
|
describe("CommandManager", () => {
|
|
31
51
|
let serverManager: ServerManager;
|
|
32
52
|
let commandManager: CommandManager;
|
|
@@ -289,6 +309,8 @@ describe("CommandManager", () => {
|
|
|
289
309
|
);
|
|
290
310
|
|
|
291
311
|
expect(editor).toBeInstanceOf(ServerSettingsList);
|
|
312
|
+
// Wait for async health checks to complete
|
|
313
|
+
await waitForEditor(editor);
|
|
292
314
|
// Seeded with the merged snapshot
|
|
293
315
|
expect(editor.render(80).join("\n")).toContain("http://seed:1");
|
|
294
316
|
|
|
@@ -302,6 +324,7 @@ describe("CommandManager", () => {
|
|
|
302
324
|
editor.handleInput(ENTER); // skip optional ID
|
|
303
325
|
expect(editor.render(80).join("\n")).toContain("Add server · 3/3");
|
|
304
326
|
editor.handleInput(ENTER); // skip optional name → persist
|
|
327
|
+
// Wait for async persist and rebuild
|
|
305
328
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
306
329
|
expect(editorSettings.setLlamaSetting).toHaveBeenCalledWith("servers", [
|
|
307
330
|
{ url: "http://seed:1" },
|
|
@@ -372,6 +395,9 @@ describe("CommandManager", () => {
|
|
|
372
395
|
vi.fn(),
|
|
373
396
|
);
|
|
374
397
|
|
|
398
|
+
// Wait for async health checks to complete
|
|
399
|
+
await waitForEditor(editor);
|
|
400
|
+
|
|
375
401
|
// Move to the second row and open the confirm dialog
|
|
376
402
|
editor.handleInput("\x1b[B");
|
|
377
403
|
editor.handleInput("d");
|
package/tests/dialog.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@earendil-works/pi-tui";
|
|
7
7
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
8
|
import { ConfirmDialog, InputDialog } from "../src/ui/dialog";
|
|
9
|
+
import { createOverrideSettingsList } from "../src/ui/overrideSettingsList";
|
|
9
10
|
import { ServerSettingsList } from "../src/ui/serverSettingsList";
|
|
10
11
|
|
|
11
12
|
beforeEach(() => {
|
|
@@ -184,3 +185,44 @@ describe("ServerSettingsList add wizard", () => {
|
|
|
184
185
|
expect(editor.render(80).join("\n")).toContain("(a) add server");
|
|
185
186
|
});
|
|
186
187
|
});
|
|
188
|
+
|
|
189
|
+
describe("OverrideEntryListEditor cost re-prefill", () => {
|
|
190
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
191
|
+
|
|
192
|
+
it("re-entering a cost field prefills the just-saved value", async () => {
|
|
193
|
+
const persist = vi.fn().mockResolvedValue(undefined);
|
|
194
|
+
const list = createOverrideSettingsList({
|
|
195
|
+
tui: createMockTui(),
|
|
196
|
+
theme: createMockTheme(),
|
|
197
|
+
keybindings: createKeybindings(),
|
|
198
|
+
servers: [
|
|
199
|
+
{
|
|
200
|
+
url: "http://x:1",
|
|
201
|
+
overrides: { "gpt-*": { cost: { input: 0.2 } } },
|
|
202
|
+
},
|
|
203
|
+
] as never[],
|
|
204
|
+
persist,
|
|
205
|
+
done: vi.fn(),
|
|
206
|
+
onError: vi.fn(),
|
|
207
|
+
onChanged: vi.fn(),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const DOWN = "\x1b[B"; // tui.select.down
|
|
211
|
+
const CLEAR = "\x15"; // tui.editor.deleteToLineStart
|
|
212
|
+
|
|
213
|
+
list.handleInput(ENTER); // drill into the server row
|
|
214
|
+
list.handleInput(ENTER); // open the field submenu (cursor: pattern)
|
|
215
|
+
list.handleInput(DOWN); // cursor: cost.input
|
|
216
|
+
list.handleInput(ENTER); // open the input dialog (prefilled "0.2")
|
|
217
|
+
list.handleInput(CLEAR);
|
|
218
|
+
for (const ch of "0.5") list.handleInput(ch);
|
|
219
|
+
list.handleInput(ENTER); // commit
|
|
220
|
+
await flush();
|
|
221
|
+
|
|
222
|
+
// Re-enter the field: the prefill must be the saved value
|
|
223
|
+
list.handleInput(ENTER);
|
|
224
|
+
const rendered = list.render(80).join("\n");
|
|
225
|
+
expect(rendered).toContain("0.5");
|
|
226
|
+
expect(rendered).not.toContain("0.2");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ServerStatus } from "../src/enums/serverStatus";
|
|
3
|
+
import { checkServerHealth } from "../src/utils/health";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stubs `global.fetch` for one test. The probe only reads the parsed body,
|
|
7
|
+
* so a fake response is just a `json()` accessor.
|
|
8
|
+
*/
|
|
9
|
+
const stubFetch = (
|
|
10
|
+
impl: (url: string, init?: RequestInit) => Promise<unknown>,
|
|
11
|
+
) => vi.stubGlobal("fetch", vi.fn(impl));
|
|
12
|
+
|
|
13
|
+
const okResponse = () => ({ json: async () => ({ status: "ok" }) });
|
|
14
|
+
|
|
15
|
+
const URL_ = "http://127.0.0.1:8080";
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.unstubAllGlobals();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("checkServerHealth", () => {
|
|
22
|
+
it("should return READY when the payload status is ok", async () => {
|
|
23
|
+
stubFetch(async () => ({ json: async () => ({ status: "ok" }) }));
|
|
24
|
+
|
|
25
|
+
expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.READY);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should return UNREACHABLE when the payload status is not ok", async () => {
|
|
29
|
+
stubFetch(async () => ({ json: async () => ({ status: "error" }) }));
|
|
30
|
+
|
|
31
|
+
expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.UNREACHABLE);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should return UNREACHABLE when the payload has no status field", async () => {
|
|
35
|
+
// llama-server answers a loading model with a 503 + error payload
|
|
36
|
+
stubFetch(async () => ({
|
|
37
|
+
json: async () => ({ error: { message: "Loading model" } }),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.UNREACHABLE);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should return UNREACHABLE when the body is not JSON", async () => {
|
|
44
|
+
stubFetch(async () => ({
|
|
45
|
+
json: async () => {
|
|
46
|
+
throw new Error("Unexpected token");
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.UNREACHABLE);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should return UNREACHABLE on a network error", async () => {
|
|
54
|
+
stubFetch(async () => {
|
|
55
|
+
throw new Error("connection refused");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.UNREACHABLE);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should return TIMEOUT when the fetch aborts with TimeoutError", async () => {
|
|
62
|
+
stubFetch(async () => {
|
|
63
|
+
throw new DOMException("The operation timed out.", "TimeoutError");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.TIMEOUT);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should return TIMEOUT when the fetch aborts with AbortError", async () => {
|
|
70
|
+
// Some runtimes surface the timeout as a plain AbortError
|
|
71
|
+
stubFetch(async () => {
|
|
72
|
+
throw new DOMException("The user aborted a request.", "AbortError");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.TIMEOUT);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should hit <url>/health", async () => {
|
|
79
|
+
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
|
|
80
|
+
okResponse(),
|
|
81
|
+
);
|
|
82
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
83
|
+
|
|
84
|
+
await checkServerHealth(URL_, 1000);
|
|
85
|
+
|
|
86
|
+
expect(fetchMock).toHaveBeenCalledWith(`${URL_}/health`, {
|
|
87
|
+
signal: expect.any(AbortSignal),
|
|
88
|
+
headers: undefined,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should send the API key as a Bearer token when provided", async () => {
|
|
93
|
+
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
|
|
94
|
+
okResponse(),
|
|
95
|
+
);
|
|
96
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
97
|
+
|
|
98
|
+
await checkServerHealth(URL_, 1000, "secret-key");
|
|
99
|
+
|
|
100
|
+
expect(fetchMock).toHaveBeenCalledWith(`${URL_}/health`, {
|
|
101
|
+
signal: expect.any(AbortSignal),
|
|
102
|
+
headers: { Authorization: "Bearer secret-key" },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should omit the Authorization header when no key is given", async () => {
|
|
107
|
+
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
|
|
108
|
+
okResponse(),
|
|
109
|
+
);
|
|
110
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
111
|
+
|
|
112
|
+
await checkServerHealth(URL_, 1000);
|
|
113
|
+
|
|
114
|
+
expect(fetchMock.mock.calls[0]?.[1]?.headers).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
package/tests/overrides.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
removeOverrideEntry,
|
|
12
12
|
updateOverrideEntry,
|
|
13
13
|
} from "../src/ui/overrideEntryEditor";
|
|
14
|
+
import { overrideFieldValue } from "../src/ui/overrideSettingsList";
|
|
14
15
|
import { createMockServer, mockRpc } from "./mocks";
|
|
15
16
|
|
|
16
17
|
beforeEach(() => {
|
|
@@ -350,3 +351,34 @@ describe("override entry list helpers", () => {
|
|
|
350
351
|
});
|
|
351
352
|
});
|
|
352
353
|
});
|
|
354
|
+
|
|
355
|
+
describe("overrideFieldValue", () => {
|
|
356
|
+
it("formats cost fields, defaulting to 0", () => {
|
|
357
|
+
expect(overrideFieldValue("cost.input", {})).toBe("0");
|
|
358
|
+
expect(overrideFieldValue("cost.input", { cost: { input: 0.2 } })).toBe(
|
|
359
|
+
"0.2",
|
|
360
|
+
);
|
|
361
|
+
expect(overrideFieldValue("cost.cacheWrite", {})).toBe("0");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("formats capabilities and reasoning labels", () => {
|
|
365
|
+
expect(overrideFieldValue("capabilities", {})).toBe("text");
|
|
366
|
+
expect(
|
|
367
|
+
overrideFieldValue("capabilities", { capabilities: ["text", "image"] }),
|
|
368
|
+
).toBe("text | image");
|
|
369
|
+
expect(overrideFieldValue("reasoning", {})).toBe("true");
|
|
370
|
+
expect(overrideFieldValue("reasoning", { reasoning: false })).toBe("false");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("formats maxTokens/contextSize, defaulting to 0", () => {
|
|
374
|
+
expect(overrideFieldValue("maxTokens", {})).toBe("0");
|
|
375
|
+
expect(overrideFieldValue("maxTokens", { maxTokens: 4096 })).toBe("4096");
|
|
376
|
+
expect(overrideFieldValue("contextSize", { contextSize: 8192 })).toBe(
|
|
377
|
+
"8192",
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("returns an empty label for unknown fields", () => {
|
|
382
|
+
expect(overrideFieldValue("other", {})).toBe("");
|
|
383
|
+
});
|
|
384
|
+
});
|
package/tests/server.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { POLLING_TIMEOUT, SERVER_TIMEOUT } from "../src/constants";
|
|
3
3
|
import { ServerStatus } from "../src/enums/serverStatus";
|
|
4
4
|
import type { LlamaSettingsManager } from "../src/managers/settings";
|
|
@@ -35,6 +35,26 @@ describe("Server providerName", () => {
|
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
describe("Server apiBaseUrl", () => {
|
|
39
|
+
it("should append the ENDPOINT_PREFIX to baseUrl", () => {
|
|
40
|
+
const server = new Server(settings, { baseUrl: "http://127.0.0.1:8080" });
|
|
41
|
+
expect(server.apiBaseUrl).toBe("http://127.0.0.1:8080/v1");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should not double the prefix when baseUrl already ends with it", () => {
|
|
45
|
+
const server = new Server(settings, {
|
|
46
|
+
baseUrl: "http://127.0.0.1:8080/v1",
|
|
47
|
+
});
|
|
48
|
+
expect(server.apiBaseUrl).toBe("http://127.0.0.1:8080/v1");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should keep baseUrl untouched for provider identity", () => {
|
|
52
|
+
const server = new Server(settings, { baseUrl: "http://127.0.0.1:8080" });
|
|
53
|
+
expect(server.baseUrl).toBe("http://127.0.0.1:8080");
|
|
54
|
+
expect(server.providerId).toBe("llama-server=http://127.0.0.1:8080");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
38
58
|
describe("Server fetchModels", () => {
|
|
39
59
|
it("should call the /models endpoint", async () => {
|
|
40
60
|
mockRpc.mockResolvedValueOnce({
|
|
@@ -195,8 +215,18 @@ describe("Server timeouts", async () => {
|
|
|
195
215
|
});
|
|
196
216
|
|
|
197
217
|
describe("Server isReady", () => {
|
|
218
|
+
// `isReady` delegates to the shared health probe (`utils/health`), which
|
|
219
|
+
// uses plain `fetch` instead of the ApiClient — stub it per test.
|
|
220
|
+
const stubFetch = (impl: () => Promise<unknown>) => {
|
|
221
|
+
vi.stubGlobal("fetch", vi.fn(impl));
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
afterEach(() => {
|
|
225
|
+
vi.unstubAllGlobals();
|
|
226
|
+
});
|
|
227
|
+
|
|
198
228
|
it("should return READY when health status is ok", async () => {
|
|
199
|
-
|
|
229
|
+
stubFetch(async () => ({ json: async () => ({ status: "ok" }) }));
|
|
200
230
|
|
|
201
231
|
const server = createMockServer();
|
|
202
232
|
const status = await server.isReady(1000);
|
|
@@ -205,7 +235,9 @@ describe("Server isReady", () => {
|
|
|
205
235
|
});
|
|
206
236
|
|
|
207
237
|
it("should return UNREACHABLE when health check fails", async () => {
|
|
208
|
-
|
|
238
|
+
stubFetch(async () => {
|
|
239
|
+
throw new Error("connection refused");
|
|
240
|
+
});
|
|
209
241
|
|
|
210
242
|
const server = createMockServer();
|
|
211
243
|
const status = await server.isReady(1000);
|
|
@@ -214,11 +246,22 @@ describe("Server isReady", () => {
|
|
|
214
246
|
});
|
|
215
247
|
|
|
216
248
|
it("should return UNREACHABLE when health status is not ok", async () => {
|
|
217
|
-
|
|
249
|
+
stubFetch(async () => ({ json: async () => ({ status: "error" }) }));
|
|
218
250
|
|
|
219
251
|
const server = createMockServer();
|
|
220
252
|
const status = await server.isReady(1000);
|
|
221
253
|
|
|
222
254
|
expect(status).toBe(ServerStatus.UNREACHABLE);
|
|
223
255
|
});
|
|
256
|
+
|
|
257
|
+
it("should return TIMEOUT when the health check aborts", async () => {
|
|
258
|
+
stubFetch(async () => {
|
|
259
|
+
throw new DOMException("The operation timed out.", "TimeoutError");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const server = createMockServer();
|
|
263
|
+
const status = await server.isReady(1000);
|
|
264
|
+
|
|
265
|
+
expect(status).toBe(ServerStatus.TIMEOUT);
|
|
266
|
+
});
|
|
224
267
|
});
|
|
@@ -69,7 +69,7 @@ describe("ServerManager", () => {
|
|
|
69
69
|
"llama-server=http://127.0.0.1:8080",
|
|
70
70
|
{
|
|
71
71
|
name: "Llama.cpp (http://127.0.0.1:8080)",
|
|
72
|
-
baseUrl: "http://127.0.0.1:8080",
|
|
72
|
+
baseUrl: "http://127.0.0.1:8080/v1",
|
|
73
73
|
api: "openai-completions",
|
|
74
74
|
apiKey: "key-1",
|
|
75
75
|
models: [{ id: "test-model" }],
|
|
@@ -79,7 +79,7 @@ describe("ServerManager", () => {
|
|
|
79
79
|
"llama-server=http://127.0.0.1:8081",
|
|
80
80
|
{
|
|
81
81
|
name: "Llama.cpp (http://127.0.0.1:8081)",
|
|
82
|
-
baseUrl: "http://127.0.0.1:8081",
|
|
82
|
+
baseUrl: "http://127.0.0.1:8081/v1",
|
|
83
83
|
api: "openai-completions",
|
|
84
84
|
apiKey: "key-2",
|
|
85
85
|
models: [{ id: "test-model" }],
|
|
@@ -133,7 +133,7 @@ describe("ServerManager", () => {
|
|
|
133
133
|
|
|
134
134
|
expect(mockPi.registerProvider).toHaveBeenCalledWith(
|
|
135
135
|
"llama-server=http://127.0.0.1:8081",
|
|
136
|
-
expect.objectContaining({ baseUrl: "http://127.0.0.1:8081" }),
|
|
136
|
+
expect.objectContaining({ baseUrl: "http://127.0.0.1:8081/v1" }),
|
|
137
137
|
);
|
|
138
138
|
expect(manager.servers).toHaveLength(2);
|
|
139
139
|
const models = await manager.getAllModels();
|
|
@@ -184,7 +184,7 @@ describe("ServerManager", () => {
|
|
|
184
184
|
);
|
|
185
185
|
expect(mockPi.registerProvider).toHaveBeenCalledWith(
|
|
186
186
|
"llama-server=http://127.0.0.1:9090",
|
|
187
|
-
expect.objectContaining({ baseUrl: "http://127.0.0.1:9090" }),
|
|
187
|
+
expect.objectContaining({ baseUrl: "http://127.0.0.1:9090/v1" }),
|
|
188
188
|
);
|
|
189
189
|
expect(manager.servers[0]?.providerId).toBe(
|
|
190
190
|
"llama-server=http://127.0.0.1:9090",
|