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 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 change is written immediately to the **project**
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.12.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.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
  /**
@@ -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
  }
@@ -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 { baseUrl, models, providerId, providerName } = server;
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: 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
- try {
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>("/v1/models");
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
- _currentValue: string,
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
- const buildOverrideFieldItems = (
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: String(entry.override.cost?.input ?? 0),
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: String(entry.override.cost?.output ?? 0),
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: String(entry.override.cost?.cacheRead ?? 0),
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: String(entry.override.cost?.cacheWrite ?? 0),
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.capabilities?.join(" | ") ?? "text",
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: String(entry.override.contextSize ?? 0),
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: String(entry.override.maxTokens ?? 0),
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
- this.submenuOpen = true;
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 — just refresh the entry's summary row
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 { formatServerSuffix, normalizeServerUrl } from "./serverListEditor";
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
- done,
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: 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.settingsList = this.buildSettingsList();
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(server, i, theme, tui, (field, value) =>
161
- this.handleFieldChange(field, value),
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.invalidate();
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.handleInput(data);
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
- this.settingsList = this.buildSettingsList();
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");
@@ -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
+ });
@@ -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
+ });
@@ -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
- mockRpc.mockResolvedValueOnce({ status: "ok" });
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
- mockRpc.mockRejectedValueOnce(new Error("connection refused"));
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
- mockRpc.mockResolvedValueOnce({ status: "error" });
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",