pi-llama-cpp 0.15.0 → 0.16.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.
@@ -74,7 +74,7 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
74
74
 
75
75
  // -- abstract hooks -------------------------------------------------------
76
76
 
77
- protected async buildSettingsList(): Promise<SettingsList> {
77
+ protected override async buildSettingsList(): Promise<SettingsList> {
78
78
  const builder = new ServerItemBuilder(this.dialogs);
79
79
  const serverTimeout = this.options.serverTimeout ?? SERVER_TIMEOUT;
80
80
  // Run auth + health probes in parallel; ⛔ wins if auth fails,
@@ -131,7 +131,7 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
131
131
  });
132
132
  }
133
133
 
134
- protected beginAdd(): void {
134
+ protected override beginAdd(): void {
135
135
  const wizard = new ServerWizard(this.dialogs, (dialog) =>
136
136
  this.openDialog(dialog),
137
137
  );
@@ -144,7 +144,7 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
144
144
  );
145
145
  }
146
146
 
147
- protected deleteSelected(): void {
147
+ protected override deleteSelected(): void {
148
148
  const idx = this.selectedIndex;
149
149
  const next = this.options.servers.filter((_, i) => i !== idx);
150
150
  void this.persistSnapshot(next, () => {
@@ -152,17 +152,17 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
152
152
  });
153
153
  }
154
154
 
155
- protected readonly emptyHintKey = "emptyServers" as const;
155
+ protected override readonly emptyHintKey = "emptyServers" as const;
156
156
 
157
- protected getRowId(index: number): string {
157
+ protected override getRowId(index: number): string {
158
158
  return `server-${index}`;
159
159
  }
160
160
 
161
- protected getRowLabel(index: number): string {
161
+ protected override getRowLabel(index: number): string {
162
162
  return this.options.servers[index]?.url ?? "";
163
163
  }
164
164
 
165
- protected get deleteTitle(): string {
165
+ protected override get deleteTitle(): string {
166
166
  return TITLES.deleteServer;
167
167
  }
168
168
 
@@ -0,0 +1,110 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ import { API_KEY_PLACEHOLDER } from "../constants";
4
+
5
+ /**
6
+ * Resolves credential API keys from `auth.json` entries.
7
+ *
8
+ * Supports four formats:
9
+ * - **Literal** — `"sk-abc123"` used as-is
10
+ * - **Shell command** — `"!cat ~/.secrets/key"` executes and captures stdout
11
+ * - **Env ref** — `"$VAR"` or `"${VAR}"` resolved from `credential.env` then `process.env`
12
+ * - **Escape** — `"$$literal"` → `"$literal"`, `"$!bang"` → `"!bang"`
13
+ */
14
+ export class CredentialResolver {
15
+ /**
16
+ * Resolves a credential key to its actual value.
17
+ *
18
+ * @param key - The raw key string from the credential
19
+ * @param env - Optional env map from `credential.env`. Checked before `process.env`.
20
+ * @returns The resolved API key value, or the placeholder on failure.
21
+ */
22
+ resolve(key: string, env?: Record<string, string>): string {
23
+ if (!key) return API_KEY_PLACEHOLDER;
24
+
25
+ if (key.startsWith("!")) return this.resolveShellCommand(key);
26
+ if (key.startsWith("$$")) return this.resolveEscape(key);
27
+ if (key.startsWith("$!")) return this.resolveEscape(key);
28
+ if (!key.startsWith("$")) return key;
29
+
30
+ return this.resolveEnvRef(key, env) ?? API_KEY_PLACEHOLDER;
31
+ }
32
+
33
+ /**
34
+ * Executes a shell command and returns its trimmed stdout.
35
+ *
36
+ * Strips the leading `!` and runs the remainder as a shell command.
37
+ * On failure (non-zero exit or exception), returns the placeholder.
38
+ *
39
+ * @param command - The full key string starting with `!` (e.g. `"!cat ~/.secrets/key"`).
40
+ * @returns The trimmed stdout, or the API key placeholder on failure.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * resolveShellCommand("!echo my-secret") // → "my-secret"
45
+ * resolveShellCommand("!cat ~/.key") // → contents of file
46
+ * resolveShellCommand("!invalid/cmd") // → API_KEY_PLACEHOLDER
47
+ * ```
48
+ */
49
+ private resolveShellCommand(command: string): string {
50
+ try {
51
+ return (
52
+ execSync(command.slice(1), {
53
+ encoding: "utf-8",
54
+ timeout: 10_000,
55
+ }).trim() || API_KEY_PLACEHOLDER
56
+ );
57
+ } catch {
58
+ return API_KEY_PLACEHOLDER;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Resolves escape sequences: `$$` → literal `$`, `$!` → literal `!`.
64
+ *
65
+ * Replaces the leading escape marker with the literal character and
66
+ * preserves any remaining text.
67
+ *
68
+ * @param key - A key string starting with `$$` or `$!`.
69
+ * @returns The literal character followed by the rest of the string.
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * resolveEscape("$$literal") // → "$literal"
74
+ * resolveEscape("$!bang") // → "!bang"
75
+ * ```
76
+ */
77
+ private resolveEscape(key: string): string {
78
+ return key.charAt(1) + key.slice(2);
79
+ }
80
+
81
+ /**
82
+ * Resolves `$VAR` or `${VAR}` syntax to the corresponding environment value.
83
+ *
84
+ * Matches the entire key against `$VAR` or `${VAR}` patterns. Looks up the
85
+ * variable first in the provided `env` map, then falls back to `process.env`.
86
+ *
87
+ * @param key - The key string containing a `$VAR` or `${VAR}` reference.
88
+ * @param env - Optional env map (e.g. from `credential.env`). Checked before `process.env`.
89
+ * @returns The resolved environment value, or `undefined` if the var is not found or the format is invalid.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * resolveEnvRef("$API_KEY", { API_KEY: "abc" }) // → "abc"
94
+ * resolveEnvRef("${API_KEY}", process.env) // → process.env.API_KEY
95
+ * resolveEnvRef("$UNSET") // → undefined
96
+ * resolveEnvRef("$invalid-var!") // → undefined (invalid name)
97
+ * ```
98
+ */
99
+ private resolveEnvRef(
100
+ key: string,
101
+ env?: Record<string, string>,
102
+ ): string | undefined {
103
+ const match =
104
+ key.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/) ??
105
+ key.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/);
106
+ const varName = match?.[1];
107
+ if (!varName) return undefined;
108
+ return env?.[varName] ?? process.env[varName];
109
+ }
110
+ }
@@ -1,5 +1,4 @@
1
1
  import { ServerStatus } from "../enums/serverStatus";
2
- import type { HealthEndpoint } from "../interfaces/endpoints/health";
3
2
 
4
3
  /**
5
4
  * Probes a llama-server's `/health` endpoint and classifies the outcome.
@@ -35,8 +34,8 @@ export const checkServerHealth = async (
35
34
  signal: AbortSignal.timeout(timeout),
36
35
  headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
37
36
  });
38
- const data = (await response.json()) as HealthEndpoint;
39
- return data.status === "ok" ? ServerStatus.READY : ServerStatus.UNREACHABLE;
37
+
38
+ return response.ok ? ServerStatus.READY : ServerStatus.UNREACHABLE;
40
39
  } catch (error) {
41
40
  // `AbortSignal.timeout` rejects `fetch` with a `TimeoutError`
42
41
  // DOMException (some runtimes surface it as `AbortError` with a
package/tests/mocks.ts CHANGED
@@ -68,6 +68,8 @@ export const createFakeClients = (): {
68
68
  get: (endpoint: string) => mockRpc(endpoint),
69
69
  post: (endpoint: string, body?: Record<string, unknown>) =>
70
70
  mockRpc(endpoint, body),
71
+ rawGet: (endpoint: string) => mockRpc(endpoint),
72
+ rawPost: (endpoint: string) => mockRpc(endpoint),
71
73
  clearCache: vi.fn(),
72
74
  } as unknown as ApiClient;
73
75
  const sseManager = {
@@ -0,0 +1,327 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { Mode } from "../../src/enums/mode";
3
+ import { Status } from "../../src/enums/status";
4
+ import { LlamaSwapModel } from "../../src/models/llamaSwapModel";
5
+ import { createMockServer, mockRpc } from "../mocks";
6
+
7
+ beforeEach(() => {
8
+ mockRpc.mockReset();
9
+ });
10
+
11
+ const createModel = (
12
+ extra: Partial<Record<string, unknown>> = {},
13
+ serverOverrides: Parameters<typeof createMockServer>[0] = {},
14
+ ): LlamaSwapModel =>
15
+ new LlamaSwapModel(
16
+ {
17
+ id: "test-model",
18
+ aliases: ["test-alias"],
19
+ tags: [],
20
+ object: "model",
21
+ owned_by: "test",
22
+ created: Date.now(),
23
+ ...extra,
24
+ } as any,
25
+ createMockServer({ baseUrl: "http://127.0.0.1:8080", ...serverOverrides }),
26
+ );
27
+
28
+ describe("LlamaSwapModel mode", () => {
29
+ it("should always return LLAMASWAP mode", () => {
30
+ const model = createModel();
31
+ expect(model.mode).toBe(Mode.LLAMASWAP);
32
+ });
33
+ });
34
+
35
+ describe("LlamaSwapModel capabilities", () => {
36
+ beforeEach(() => {
37
+ mockRpc.mockReset().mockResolvedValue({
38
+ data: [
39
+ {
40
+ id: "test-model",
41
+ architecture: {
42
+ input_modalities: ["text", "image"],
43
+ output_modalities: ["text"],
44
+ },
45
+ },
46
+ ],
47
+ });
48
+ });
49
+
50
+ it("should detect image capability when input_modalities includes image", async () => {
51
+ const model = createModel();
52
+ const { input } = await model.toProviderConfig();
53
+
54
+ expect(input).toEqual(["text", "image"]);
55
+ });
56
+
57
+ it("should detect text-only capability when input_modalities only has text", async () => {
58
+ mockRpc.mockResolvedValueOnce({
59
+ data: [
60
+ {
61
+ id: "test-model",
62
+ architecture: {
63
+ input_modalities: ["text"],
64
+ output_modalities: ["text"],
65
+ },
66
+ },
67
+ ],
68
+ });
69
+
70
+ const model = createModel();
71
+ const { input } = await model.toProviderConfig();
72
+
73
+ expect(input).toEqual(["text"]);
74
+ });
75
+
76
+ it("should return text-only when model is not found in fetchModels response", async () => {
77
+ mockRpc.mockResolvedValueOnce({
78
+ data: [
79
+ {
80
+ id: "other-model",
81
+ architecture: {
82
+ input_modalities: ["text", "image"],
83
+ output_modalities: ["text"],
84
+ },
85
+ },
86
+ ],
87
+ });
88
+
89
+ const model = createModel();
90
+ const { input } = await model.toProviderConfig();
91
+
92
+ expect(input).toEqual(["text"]);
93
+ });
94
+
95
+ it("should return text-only when architecture is undefined", async () => {
96
+ mockRpc.mockResolvedValueOnce({
97
+ data: [
98
+ {
99
+ id: "test-model",
100
+ },
101
+ ],
102
+ });
103
+
104
+ const model = createModel();
105
+ const { input } = await model.toProviderConfig();
106
+
107
+ expect(input).toEqual(["text"]);
108
+ });
109
+ });
110
+
111
+ describe("LlamaSwapModel overrides", () => {
112
+ it("should use contextSize override when set", async () => {
113
+ mockRpc.mockResolvedValueOnce({
114
+ data: [{ id: "test-model" }],
115
+ });
116
+
117
+ const model = createModel(
118
+ {},
119
+ {
120
+ overrides: { "test-model": { contextSize: 65536 } },
121
+ },
122
+ );
123
+
124
+ const { contextWindow } = await model.toProviderConfig();
125
+ expect(contextWindow).toBe(65536);
126
+ });
127
+
128
+ it("should use capabilities override when set", async () => {
129
+ mockRpc.mockResolvedValueOnce({
130
+ data: [{ id: "test-model" }],
131
+ });
132
+
133
+ const model = createModel(
134
+ {},
135
+ {
136
+ overrides: { "test-model": { capabilities: ["text"] } },
137
+ },
138
+ );
139
+
140
+ const { input } = await model.toProviderConfig();
141
+ expect(input).toEqual(["text"]);
142
+ });
143
+
144
+ it("should fall through to detection when no override matches", async () => {
145
+ const model = createModel(
146
+ {},
147
+ {
148
+ overrides: { "other-model": { contextSize: 65536 } },
149
+ },
150
+ );
151
+
152
+ mockRpc
153
+ .mockResolvedValueOnce({
154
+ data: [
155
+ {
156
+ id: "test-model",
157
+ architecture: {
158
+ input_modalities: ["text", "image"],
159
+ output_modalities: ["text"],
160
+ },
161
+ },
162
+ ],
163
+ })
164
+ .mockResolvedValueOnce({
165
+ data: [
166
+ {
167
+ id: "test-model",
168
+ architecture: {
169
+ input_modalities: ["text", "image"],
170
+ output_modalities: ["text"],
171
+ },
172
+ },
173
+ ],
174
+ });
175
+
176
+ const { input } = await model.toProviderConfig();
177
+ expect(input).toEqual(["text", "image"]);
178
+ });
179
+ });
180
+
181
+ describe("LlamaSwapModel status", () => {
182
+ it("should return LOADED when status.value is 'loaded'", async () => {
183
+ mockRpc.mockResolvedValueOnce({
184
+ data: [
185
+ {
186
+ id: "test-model",
187
+ status: {
188
+ value: "loaded",
189
+ args: [],
190
+ preset: "default",
191
+ failed: false,
192
+ },
193
+ },
194
+ ],
195
+ });
196
+
197
+ const model = createModel();
198
+ const status = await model.getStatus();
199
+
200
+ expect(status).toBe(Status.LOADED);
201
+ });
202
+
203
+ it("should return UNLOADED when status.value is 'unloaded'", async () => {
204
+ mockRpc.mockResolvedValueOnce({
205
+ data: [
206
+ {
207
+ id: "test-model",
208
+ status: {
209
+ value: "unloaded",
210
+ args: [],
211
+ preset: "default",
212
+ failed: false,
213
+ },
214
+ },
215
+ ],
216
+ });
217
+
218
+ const model = createModel();
219
+ const status = await model.getStatus();
220
+
221
+ expect(status).toBe(Status.UNLOADED);
222
+ });
223
+
224
+ it("should return UNLOADED when status.value is something other than 'loaded'", async () => {
225
+ mockRpc.mockResolvedValueOnce({
226
+ data: [
227
+ {
228
+ id: "test-model",
229
+ status: {
230
+ value: "loading",
231
+ args: [],
232
+ preset: "default",
233
+ failed: false,
234
+ },
235
+ },
236
+ ],
237
+ });
238
+
239
+ const model = createModel();
240
+ const status = await model.getStatus();
241
+
242
+ expect(status).toBe(Status.UNLOADED);
243
+ });
244
+
245
+ it("should return UNLOADED when model is not found in fetchModels response", async () => {
246
+ mockRpc.mockResolvedValueOnce({
247
+ data: [
248
+ {
249
+ id: "other-model",
250
+ status: {
251
+ value: "loaded",
252
+ args: [],
253
+ preset: "default",
254
+ failed: false,
255
+ },
256
+ },
257
+ ],
258
+ });
259
+
260
+ const model = createModel();
261
+ const status = await model.getStatus();
262
+
263
+ expect(status).toBe(Status.UNLOADED);
264
+ });
265
+
266
+ it("should return UNLOADED when status is undefined", async () => {
267
+ mockRpc.mockResolvedValueOnce({
268
+ data: [
269
+ {
270
+ id: "test-model",
271
+ },
272
+ ],
273
+ });
274
+
275
+ const model = createModel();
276
+ const status = await model.getStatus();
277
+
278
+ expect(status).toBe(Status.UNLOADED);
279
+ });
280
+ });
281
+
282
+ describe("LlamaSwapModel load", () => {
283
+ it("should call GET to /upstream/{id} when model is not loaded", async () => {
284
+ const model = createModel();
285
+ // Override getStatus to return UNLOADED so load proceeds
286
+ model.getStatus = vi.fn().mockResolvedValue(Status.UNLOADED);
287
+
288
+ // mockRpc is used by ApiClient; load -> llamaSwapLoad -> apiClient.get
289
+ mockRpc.mockResolvedValue({});
290
+
291
+ await model.load();
292
+
293
+ expect(mockRpc).toHaveBeenCalledWith("/upstream/test-model");
294
+ });
295
+
296
+ it("should throw when the GET request fails", async () => {
297
+ const model = createModel();
298
+ model.getStatus = vi.fn().mockResolvedValue(Status.UNLOADED);
299
+
300
+ mockRpc.mockRejectedValue(new Error("GET failed"));
301
+
302
+ await expect(model.load()).rejects.toThrow(
303
+ "Model loading failed: test-model",
304
+ );
305
+ });
306
+
307
+ it("should not call fetch when model is already loaded", async () => {
308
+ const model = createModel();
309
+ model.getStatus = vi.fn().mockResolvedValue(Status.LOADED);
310
+
311
+ await model.load();
312
+
313
+ expect(mockRpc).not.toHaveBeenCalled();
314
+ });
315
+ });
316
+
317
+ describe("LlamaSwapModel unload", () => {
318
+ it("should call POST to /api/models/unload/{id}", async () => {
319
+ const model = createModel();
320
+
321
+ mockRpc.mockResolvedValue({});
322
+
323
+ await model.unload();
324
+
325
+ expect(mockRpc).toHaveBeenCalledWith("/api/models/unload/test-model");
326
+ });
327
+ });
@@ -10,7 +10,7 @@ const stubFetch = (
10
10
  impl: (url: string, init?: RequestInit) => Promise<unknown>,
11
11
  ) => vi.stubGlobal("fetch", vi.fn(impl));
12
12
 
13
- const okResponse = () => ({ json: async () => ({ status: "ok" }) });
13
+ const okResponse = () => ({ ok: true, json: async () => ({ status: "ok" }) });
14
14
 
15
15
  const URL_ = "http://127.0.0.1:8080";
16
16
 
@@ -20,7 +20,7 @@ afterEach(() => {
20
20
 
21
21
  describe("checkServerHealth", () => {
22
22
  it("should return READY when the payload status is ok", async () => {
23
- stubFetch(async () => ({ json: async () => ({ status: "ok" }) }));
23
+ stubFetch(async () => ({ ok: true, json: async () => ({ status: "ok" }) }));
24
24
 
25
25
  expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.READY);
26
26
  });
@@ -1,6 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { POLLING_TIMEOUT, SERVER_TIMEOUT } from "../../src/constants";
3
+ import { Mode } from "../../src/enums/mode";
3
4
  import { ServerStatus } from "../../src/enums/serverStatus";
5
+ import type { PropsEndpoint } from "../../src/interfaces/endpoints/props";
4
6
  import type { LlamaSettingsManager } from "../../src/managers/settings";
5
7
  import { Server } from "../../src/server";
6
8
  import { createMockServer, makeSettingsStub, mockRpc } from "../mocks";
@@ -133,9 +135,57 @@ describe("Server fetchServerProps", () => {
133
135
  const server = createMockServer();
134
136
  const result = await server.fetchServerProps();
135
137
 
136
- expect(result.role).toBe("router");
138
+ // Test mocks return a PropsEndpoint shape
139
+ expect((result as PropsEndpoint).role).toBe("router");
137
140
  expect(mockRpc).toHaveBeenCalledWith("/props?autoload=false");
138
141
  });
142
+
143
+ it("should return LlamaSwapPropsError shape for llama-swap", async () => {
144
+ mockRpc.mockResolvedValueOnce({
145
+ src: "llama-swap",
146
+ error: {
147
+ message: "no model id could be identified",
148
+ type: "invalid_request_error",
149
+ param: null,
150
+ code: "not_found",
151
+ },
152
+ });
153
+
154
+ const server = createMockServer();
155
+ const result = await server.fetchServerProps();
156
+
157
+ expect((result as { src: string }).src).toBe("llama-swap");
158
+ expect(mockRpc).toHaveBeenCalledWith("/props?autoload=false");
159
+ });
160
+ });
161
+
162
+ describe("Server detectServerMode", () => {
163
+ it("should detect LLAMASWAP mode when /props returns src", async () => {
164
+ // initialize() calls fetchModels() first, then fetchServerProps()
165
+ mockRpc
166
+ .mockResolvedValueOnce({
167
+ data: [{ id: "test-model" }],
168
+ object: "list",
169
+ })
170
+ .mockResolvedValueOnce({
171
+ src: "llama-swap",
172
+ error: {
173
+ message: "no model id could be identified",
174
+ type: "invalid_request_error",
175
+ param: null,
176
+ code: "not_found",
177
+ },
178
+ });
179
+
180
+ const server = createMockServer({
181
+ initialize: async () => {
182
+ await Server.prototype.initialize.call(server);
183
+ },
184
+ });
185
+ await server.initialize();
186
+
187
+ expect(server.models[0].mode).toBe(Mode.LLAMASWAP);
188
+ });
139
189
  });
140
190
 
141
191
  describe("Server postRequest", () => {
@@ -214,7 +264,7 @@ describe("Server isReady", () => {
214
264
  });
215
265
 
216
266
  it("should return READY when health status is ok", async () => {
217
- stubFetch(async () => ({ json: async () => ({ status: "ok" }) }));
267
+ stubFetch(async () => ({ ok: true, json: async () => ({ status: "ok" }) }));
218
268
 
219
269
  const server = createMockServer();
220
270
  const status = await server.isReady(1000);