pi-llama-cpp 0.10.0 → 0.11.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 +66 -5
- package/package.json +3 -3
- package/src/api/client.ts +76 -32
- package/src/constants.ts +15 -0
- package/src/index.ts +3 -5
- package/src/interfaces/events.ts +14 -3
- package/src/interfaces/server.ts +20 -0
- package/src/interfaces/settings.ts +11 -2
- package/src/managers/command.ts +285 -43
- package/src/managers/events.ts +28 -10
- package/src/managers/server.ts +87 -16
- package/src/managers/settings.ts +78 -16
- package/src/models/baseModel.ts +0 -8
- package/src/server.ts +69 -28
- package/src/sse/client.ts +28 -16
- package/src/sse/manager.ts +22 -10
- package/src/ui/serverListEditor.ts +481 -0
- package/src/utils/errors.ts +5 -0
- package/src/utils/settingsStore.ts +60 -0
- package/src/utils/urls.ts +16 -0
- package/tests/commandManager.test.ts +256 -11
- package/tests/events.test.ts +117 -88
- package/tests/mocks.ts +145 -32
- package/tests/server.test.ts +37 -35
- package/tests/serverListEditor.test.ts +637 -0
- package/tests/serverManager.test.ts +282 -39
- package/tests/settings.test.ts +173 -16
- package/tests/settingsStore.test.ts +209 -0
- package/tests/sseManager.test.ts +88 -11
- package/src/interfaces/auth.ts +0 -6
- package/src/utils/cache.ts +0 -39
- package/src/utils/mutex.ts +0 -24
|
@@ -2,14 +2,37 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { ServerManager } from "../src/managers/server";
|
|
3
3
|
import { BaseModel } from "../src/models/baseModel";
|
|
4
4
|
import { Server } from "../src/server";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createMockModel,
|
|
7
|
+
createMockServer,
|
|
8
|
+
makeSettingsStub,
|
|
9
|
+
mockRpc,
|
|
10
|
+
} from "./mocks";
|
|
11
|
+
|
|
12
|
+
// Injected settings stub — the single instance passed to ServerManager and
|
|
13
|
+
// to every real `new Server(...)` construction below (Server's eager
|
|
14
|
+
// getApiKey() resolves the stub's placeholder, never consumed). Tests
|
|
15
|
+
// re-stub members per case instead of module-mocking the singleton.
|
|
16
|
+
const settingsStub = makeSettingsStub();
|
|
6
17
|
|
|
7
18
|
const mockPi = {
|
|
8
19
|
registerProvider: vi.fn(),
|
|
20
|
+
unregisterProvider: vi.fn(),
|
|
9
21
|
registerCommand: vi.fn(),
|
|
10
22
|
setModel: vi.fn(),
|
|
11
23
|
};
|
|
12
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Creates a ServerManager whose list is driven by the mocked
|
|
27
|
+
* `settings.resolveServers()` — the same path a real scan takes.
|
|
28
|
+
*/
|
|
29
|
+
const createManager = async (...servers: Server[]): Promise<ServerManager> => {
|
|
30
|
+
vi.mocked(settingsStub.resolveServers).mockReturnValue(servers);
|
|
31
|
+
const manager = new ServerManager(settingsStub);
|
|
32
|
+
await manager.update(mockPi as any);
|
|
33
|
+
return manager;
|
|
34
|
+
};
|
|
35
|
+
|
|
13
36
|
beforeEach(() => {
|
|
14
37
|
vi.clearAllMocks();
|
|
15
38
|
mockRpc.mockImplementation((endpoint: string, fallback?: unknown) => {
|
|
@@ -24,22 +47,34 @@ beforeEach(() => {
|
|
|
24
47
|
|
|
25
48
|
describe("Server", () => {
|
|
26
49
|
it("should generate provider IDs from URLs", () => {
|
|
27
|
-
const server1 = new Server(
|
|
50
|
+
const server1 = new Server(settingsStub, {
|
|
51
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
52
|
+
});
|
|
28
53
|
expect(server1.providerId).toBe("llama-server=http://127.0.0.1:8080");
|
|
29
|
-
const server2 = new Server(
|
|
54
|
+
const server2 = new Server(settingsStub, {
|
|
55
|
+
baseUrl: "http://10.0.0.5:8080",
|
|
56
|
+
});
|
|
30
57
|
expect(server2.providerId).toBe("llama-server=http://10.0.0.5:8080");
|
|
31
|
-
const server3 = new Server("http://127.0.0.1");
|
|
58
|
+
const server3 = new Server(settingsStub, { baseUrl: "http://127.0.0.1" });
|
|
32
59
|
expect(server3.providerId).toBe("llama-server=http://127.0.0.1");
|
|
33
|
-
const server4 = new Server(
|
|
60
|
+
const server4 = new Server(settingsStub, {
|
|
61
|
+
baseUrl: "http://127.0.0.1:80",
|
|
62
|
+
});
|
|
34
63
|
expect(server4.providerId).toBe("llama-server=http://127.0.0.1:80");
|
|
35
|
-
const server5 = new Server(
|
|
64
|
+
const server5 = new Server(settingsStub, {
|
|
65
|
+
baseUrl: "https://127.0.0.1:443",
|
|
66
|
+
});
|
|
36
67
|
expect(server5.providerId).toBe("llama-server=https://127.0.0.1:443");
|
|
37
68
|
});
|
|
38
69
|
|
|
39
70
|
it("should generate provider names from URLs", () => {
|
|
40
|
-
const server1 = new Server(
|
|
71
|
+
const server1 = new Server(settingsStub, {
|
|
72
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
73
|
+
});
|
|
41
74
|
expect(server1.providerName).toBe("Llama.cpp (http://127.0.0.1:8080)");
|
|
42
|
-
const server2 = new Server(
|
|
75
|
+
const server2 = new Server(settingsStub, {
|
|
76
|
+
baseUrl: "http://10.0.0.5:8080",
|
|
77
|
+
});
|
|
43
78
|
expect(server2.providerName).toBe("Llama.cpp (http://10.0.0.5:8080)");
|
|
44
79
|
});
|
|
45
80
|
});
|
|
@@ -51,32 +86,17 @@ describe("ServerManager", () => {
|
|
|
51
86
|
id: "test-model",
|
|
52
87
|
toProviderConfig: vi.fn().mockResolvedValue({ id: "test-model" }),
|
|
53
88
|
} as unknown as BaseModel;
|
|
54
|
-
mockRpc.mockImplementation((endpoint: string, fallback?: unknown) => {
|
|
55
|
-
if (endpoint === "/v1/models") {
|
|
56
|
-
return Promise.resolve({ data: [mockModel], object: "list" });
|
|
57
|
-
}
|
|
58
|
-
const defaults: Record<string, unknown> = {
|
|
59
|
-
"/health": { status: "ok" },
|
|
60
|
-
"/props?autoload=false": { role: "router" },
|
|
61
|
-
};
|
|
62
|
-
return Promise.resolve(defaults[endpoint] ?? fallback ?? {});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
89
|
const server1 = createMockServer({
|
|
66
90
|
baseUrl: "http://127.0.0.1:8080",
|
|
67
91
|
apiKey: "key-1",
|
|
68
|
-
|
|
69
|
-
providerName: "Llama.cpp (http://127.0.0.1:8080)",
|
|
92
|
+
models: [mockModel],
|
|
70
93
|
});
|
|
71
94
|
const server2 = createMockServer({
|
|
72
95
|
baseUrl: "http://127.0.0.1:8081",
|
|
73
96
|
apiKey: "key-2",
|
|
74
|
-
|
|
75
|
-
providerName: "Llama.cpp (http://127.0.0.1:8081)",
|
|
97
|
+
models: [mockModel],
|
|
76
98
|
});
|
|
77
|
-
const manager =
|
|
78
|
-
|
|
79
|
-
await manager.initialize(mockPi as any);
|
|
99
|
+
const manager = await createManager(server1, server2);
|
|
80
100
|
|
|
81
101
|
expect(mockPi.registerProvider).toHaveBeenCalledTimes(2);
|
|
82
102
|
expect(mockPi.registerProvider).toHaveBeenCalledWith(
|
|
@@ -101,25 +121,18 @@ describe("ServerManager", () => {
|
|
|
101
121
|
);
|
|
102
122
|
});
|
|
103
123
|
|
|
104
|
-
it("should return all models from all servers", () => {
|
|
105
|
-
const mockModel1 =
|
|
106
|
-
|
|
107
|
-
id: "model-1",
|
|
108
|
-
} as unknown as BaseModel;
|
|
109
|
-
const mockModel2 = {
|
|
110
|
-
name: "model-2",
|
|
111
|
-
id: "model-2",
|
|
112
|
-
} as unknown as BaseModel;
|
|
124
|
+
it("should return all models from all servers", async () => {
|
|
125
|
+
const mockModel1 = createMockModel("model-1");
|
|
126
|
+
const mockModel2 = createMockModel("model-2");
|
|
113
127
|
const server1 = createMockServer({
|
|
114
128
|
baseUrl: "http://127.0.0.1:8080",
|
|
129
|
+
models: [mockModel1],
|
|
115
130
|
});
|
|
116
131
|
const server2 = createMockServer({
|
|
117
132
|
baseUrl: "http://127.0.0.1:8081",
|
|
133
|
+
models: [mockModel2],
|
|
118
134
|
});
|
|
119
|
-
const manager =
|
|
120
|
-
{ ...server1, models: [mockModel1] } as any,
|
|
121
|
-
{ ...server2, models: [mockModel2] } as any,
|
|
122
|
-
] as any);
|
|
135
|
+
const manager = await createManager(server1, server2);
|
|
123
136
|
|
|
124
137
|
const allModels = manager.getAllModels();
|
|
125
138
|
|
|
@@ -127,4 +140,234 @@ describe("ServerManager", () => {
|
|
|
127
140
|
expect(allModels[0]).toBe(mockModel1);
|
|
128
141
|
expect(allModels[1]).toBe(mockModel2);
|
|
129
142
|
});
|
|
143
|
+
|
|
144
|
+
describe("live server list (re-derived from settings)", () => {
|
|
145
|
+
/** Mock server; providerId/name derive from the URL as in production. */
|
|
146
|
+
const makeServer = (url: string, modelName?: string): Server =>
|
|
147
|
+
createMockServer({
|
|
148
|
+
baseUrl: url,
|
|
149
|
+
...(modelName
|
|
150
|
+
? { models: [createMockModel(modelName, { serverUrl: url })] }
|
|
151
|
+
: {}),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should register servers added after the first scan", async () => {
|
|
155
|
+
const server1 = makeServer("http://127.0.0.1:8080", "model-1");
|
|
156
|
+
vi.mocked(settingsStub.resolveServers).mockReturnValue([server1]);
|
|
157
|
+
const manager = new ServerManager(settingsStub);
|
|
158
|
+
await manager.update(mockPi as any);
|
|
159
|
+
expect(manager.servers).toHaveLength(1);
|
|
160
|
+
|
|
161
|
+
const server2 = makeServer("http://127.0.0.1:8081", "model-2");
|
|
162
|
+
vi.mocked(settingsStub.resolveServers).mockReturnValue([
|
|
163
|
+
server1,
|
|
164
|
+
server2,
|
|
165
|
+
]);
|
|
166
|
+
await manager.update(mockPi as any);
|
|
167
|
+
|
|
168
|
+
expect(mockPi.registerProvider).toHaveBeenCalledWith(
|
|
169
|
+
"llama-server=http://127.0.0.1:8081",
|
|
170
|
+
expect.objectContaining({ baseUrl: "http://127.0.0.1:8081" }),
|
|
171
|
+
);
|
|
172
|
+
expect(manager.servers).toHaveLength(2);
|
|
173
|
+
expect(manager.getAllModels().map((m) => m.id)).toEqual([
|
|
174
|
+
"model-1",
|
|
175
|
+
"model-2",
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should unregister and disconnect removed servers on the next scan", async () => {
|
|
180
|
+
const disconnectKept = vi.fn();
|
|
181
|
+
const disconnectRemoved = vi.fn();
|
|
182
|
+
const server1 = makeServer("http://127.0.0.1:8080", "model-1");
|
|
183
|
+
const server2 = makeServer("http://127.0.0.1:8081", "model-2");
|
|
184
|
+
// createMockServer stubs initialize out (models are pre-seeded), so the
|
|
185
|
+
// SSE managers never get wired; seed the slots ServerManager reaches
|
|
186
|
+
// disconnect through, mirroring an initialized server.
|
|
187
|
+
(server1 as any).sse = { disconnect: disconnectKept };
|
|
188
|
+
(server2 as any).sse = { disconnect: disconnectRemoved };
|
|
189
|
+
|
|
190
|
+
const manager = await createManager(server1, server2);
|
|
191
|
+
expect(manager.servers).toHaveLength(2);
|
|
192
|
+
|
|
193
|
+
vi.mocked(settingsStub.resolveServers).mockReturnValue([server1]);
|
|
194
|
+
await manager.update(mockPi as any);
|
|
195
|
+
|
|
196
|
+
expect(mockPi.unregisterProvider).toHaveBeenCalledTimes(1);
|
|
197
|
+
expect(mockPi.unregisterProvider).toHaveBeenCalledWith(
|
|
198
|
+
"llama-server=http://127.0.0.1:8081",
|
|
199
|
+
);
|
|
200
|
+
expect(disconnectRemoved).toHaveBeenCalledTimes(1);
|
|
201
|
+
// Surviving providers keep their SSE manager connected (today's behavior)
|
|
202
|
+
expect(disconnectKept).not.toHaveBeenCalled();
|
|
203
|
+
expect(manager.getAllModels().map((m) => m.id)).toEqual(["model-1"]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("should unregister the old provider when a server URL changes", async () => {
|
|
207
|
+
const manager = await createManager(
|
|
208
|
+
makeServer("http://127.0.0.1:8080", "model-1"),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
vi.mocked(settingsStub.resolveServers).mockReturnValue([
|
|
212
|
+
makeServer("http://127.0.0.1:9090", "model-1"),
|
|
213
|
+
]);
|
|
214
|
+
await manager.update(mockPi as any);
|
|
215
|
+
|
|
216
|
+
expect(mockPi.unregisterProvider).toHaveBeenCalledWith(
|
|
217
|
+
"llama-server=http://127.0.0.1:8080",
|
|
218
|
+
);
|
|
219
|
+
expect(mockPi.registerProvider).toHaveBeenCalledWith(
|
|
220
|
+
"llama-server=http://127.0.0.1:9090",
|
|
221
|
+
expect.objectContaining({ baseUrl: "http://127.0.0.1:9090" }),
|
|
222
|
+
);
|
|
223
|
+
expect(manager.servers[0]?.providerId).toBe(
|
|
224
|
+
"llama-server=http://127.0.0.1:9090",
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should re-register same-id servers without unregistering (name edit)", async () => {
|
|
229
|
+
const original = createMockServer({
|
|
230
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
231
|
+
customId: "my-custom-id",
|
|
232
|
+
customName: "A",
|
|
233
|
+
});
|
|
234
|
+
const manager = await createManager(original);
|
|
235
|
+
|
|
236
|
+
const renamed = createMockServer({
|
|
237
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
238
|
+
customId: "my-custom-id",
|
|
239
|
+
customName: "B",
|
|
240
|
+
});
|
|
241
|
+
vi.mocked(settingsStub.resolveServers).mockReturnValue([renamed]);
|
|
242
|
+
await manager.update(mockPi as any);
|
|
243
|
+
|
|
244
|
+
expect(mockPi.unregisterProvider).not.toHaveBeenCalled();
|
|
245
|
+
expect(vi.mocked(mockPi.registerProvider).mock.calls.at(-1)).toEqual([
|
|
246
|
+
"my-custom-id",
|
|
247
|
+
expect.objectContaining({ name: "Llama.cpp (B)" }),
|
|
248
|
+
]);
|
|
249
|
+
expect(manager.servers[0]?.providerId).toBe("my-custom-id");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("should collapse duplicate URLs into a single server", async () => {
|
|
253
|
+
const first = makeServer("http://127.0.0.1:8080", "model-1");
|
|
254
|
+
const duplicate = makeServer("http://127.0.0.1:8080", "model-1");
|
|
255
|
+
const manager = await createManager(first, duplicate);
|
|
256
|
+
|
|
257
|
+
expect(manager.servers).toHaveLength(1);
|
|
258
|
+
expect(mockPi.registerProvider).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(manager.getAllModels()).toHaveLength(1);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should return undefined from getServer for a removed server's model", async () => {
|
|
263
|
+
const manager = await createManager(
|
|
264
|
+
makeServer("http://127.0.0.1:8080", "model-1"),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const live = manager.getAllModels()[0];
|
|
268
|
+
expect(manager.getServer(live)).toBe(manager.servers[0]);
|
|
269
|
+
|
|
270
|
+
const orphan = { serverUrl: "http://gone:1" } as unknown as BaseModel;
|
|
271
|
+
expect(manager.getServer(orphan)).toBeUndefined();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("sortBy", () => {
|
|
276
|
+
const sortBy = () => vi.mocked(settingsStub.resolveSortBy);
|
|
277
|
+
|
|
278
|
+
it("should return models in API order when sortBy is 'api'", async () => {
|
|
279
|
+
sortBy().mockReturnValue("api");
|
|
280
|
+
|
|
281
|
+
const mockModelA = createMockModel("model-a");
|
|
282
|
+
const mockModelZ = createMockModel("model-z");
|
|
283
|
+
const manager = await createManager(
|
|
284
|
+
createMockServer({
|
|
285
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
286
|
+
models: [mockModelA, mockModelZ],
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const allModels = manager.getAllModels();
|
|
291
|
+
|
|
292
|
+
expect(allModels).toHaveLength(2);
|
|
293
|
+
expect(allModels[0]).toBe(mockModelA);
|
|
294
|
+
expect(allModels[1]).toBe(mockModelZ);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("should sort models by ID ascending when sortBy is 'asc'", async () => {
|
|
298
|
+
sortBy().mockReturnValue("asc");
|
|
299
|
+
|
|
300
|
+
const mockModelZ = createMockModel("model-z");
|
|
301
|
+
const mockModelA = createMockModel("model-a");
|
|
302
|
+
const manager = await createManager(
|
|
303
|
+
createMockServer({
|
|
304
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
305
|
+
models: [mockModelZ, mockModelA],
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const allModels = manager.getAllModels();
|
|
310
|
+
|
|
311
|
+
expect(allModels).toHaveLength(2);
|
|
312
|
+
expect(allModels[0]).toBe(mockModelA);
|
|
313
|
+
expect(allModels[1]).toBe(mockModelZ);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("should sort models by ID descending when sortBy is 'desc'", async () => {
|
|
317
|
+
sortBy().mockReturnValue("desc");
|
|
318
|
+
|
|
319
|
+
const mockModelA = createMockModel("model-a");
|
|
320
|
+
const mockModelZ = createMockModel("model-z");
|
|
321
|
+
const manager = await createManager(
|
|
322
|
+
createMockServer({
|
|
323
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
324
|
+
models: [mockModelA, mockModelZ],
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const allModels = manager.getAllModels();
|
|
329
|
+
|
|
330
|
+
expect(allModels).toHaveLength(2);
|
|
331
|
+
expect(allModels[0]).toBe(mockModelZ);
|
|
332
|
+
expect(allModels[1]).toBe(mockModelA);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("should sort models by name ascending when sortBy is 'asc-name'", async () => {
|
|
336
|
+
sortBy().mockReturnValue("asc-name");
|
|
337
|
+
|
|
338
|
+
const mockModelB = createMockModel("zebra", { id: "model-b" });
|
|
339
|
+
const mockModelA = createMockModel("alpha", { id: "model-a" });
|
|
340
|
+
const manager = await createManager(
|
|
341
|
+
createMockServer({
|
|
342
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
343
|
+
models: [mockModelB, mockModelA],
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const allModels = manager.getAllModels();
|
|
348
|
+
|
|
349
|
+
expect(allModels).toHaveLength(2);
|
|
350
|
+
expect(allModels[0]).toBe(mockModelA);
|
|
351
|
+
expect(allModels[1]).toBe(mockModelB);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("should sort models by name descending when sortBy is 'desc-name'", async () => {
|
|
355
|
+
sortBy().mockReturnValue("desc-name");
|
|
356
|
+
|
|
357
|
+
const mockModelA = createMockModel("alpha", { id: "model-a" });
|
|
358
|
+
const mockModelB = createMockModel("zebra", { id: "model-b" });
|
|
359
|
+
const manager = await createManager(
|
|
360
|
+
createMockServer({
|
|
361
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
362
|
+
models: [mockModelA, mockModelB],
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const allModels = manager.getAllModels();
|
|
367
|
+
|
|
368
|
+
expect(allModels).toHaveLength(2);
|
|
369
|
+
expect(allModels[0]).toBe(mockModelB);
|
|
370
|
+
expect(allModels[1]).toBe(mockModelA);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
130
373
|
});
|
package/tests/settings.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
3
|
import {
|
|
3
4
|
API_KEY_PLACEHOLDER,
|
|
@@ -17,6 +18,7 @@ const mockSettingsManager = vi.hoisted(() => ({
|
|
|
17
18
|
getGlobalSettings: vi.fn(),
|
|
18
19
|
getDefaultThinkingLevel: vi.fn(),
|
|
19
20
|
getThinkingBudgets: vi.fn(),
|
|
21
|
+
reload: vi.fn(),
|
|
20
22
|
}));
|
|
21
23
|
|
|
22
24
|
// Mock getAgentDir, readStoredCredential, and SettingsManager before importing resolver
|
|
@@ -31,6 +33,8 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
|
31
33
|
|
|
32
34
|
vi.mock("node:fs/promises", () => ({
|
|
33
35
|
readFile: vi.fn(),
|
|
36
|
+
writeFile: vi.fn(),
|
|
37
|
+
rename: vi.fn(),
|
|
34
38
|
}));
|
|
35
39
|
|
|
36
40
|
// Import mocked modules
|
|
@@ -131,6 +135,34 @@ describe("URL resolution fallback chain", () => {
|
|
|
131
135
|
|
|
132
136
|
expect(result).toEqual(["http://first:8080", "http://second:9090"]);
|
|
133
137
|
});
|
|
138
|
+
|
|
139
|
+
it("should drop env URLs without an http(s) scheme, warn, and fall through", async () => {
|
|
140
|
+
process.env.LLAMA_SERVER_URL = "127.0.0.1:8080";
|
|
141
|
+
|
|
142
|
+
const result = settings.resolveUrls();
|
|
143
|
+
|
|
144
|
+
expect(result).toEqual([LLAMA_SERVER_URL]);
|
|
145
|
+
expect(settings.takeWarnings()).toEqual([
|
|
146
|
+
"Ignoring invalid server URL '127.0.0.1:8080' (needs http(s)://)",
|
|
147
|
+
]);
|
|
148
|
+
expect(settings.takeWarnings()).toEqual([]); // drained
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("should drop server entries without an http(s) scheme and warn", async () => {
|
|
152
|
+
mockGetProjectSettings.mockReturnValue({
|
|
153
|
+
llamaSettings: {
|
|
154
|
+
servers: [{ url: "127.0.0.1:8080" }, { url: "http://good:8080/" }],
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const result = settings.resolveUrls();
|
|
159
|
+
|
|
160
|
+
expect(result).toEqual(["http://good:8080"]);
|
|
161
|
+
expect(settings.takeWarnings()).toEqual([
|
|
162
|
+
"Ignoring invalid server URL '127.0.0.1:8080' (needs http(s)://)",
|
|
163
|
+
]);
|
|
164
|
+
expect(settings.takeWarnings()).toEqual([]); // drained
|
|
165
|
+
});
|
|
134
166
|
});
|
|
135
167
|
|
|
136
168
|
describe("llamaSettings.servers resolution", () => {
|
|
@@ -315,13 +347,16 @@ describe("API key resolution", () => {
|
|
|
315
347
|
|
|
316
348
|
describe("Server with custom id", () => {
|
|
317
349
|
it("should use custom id as providerId when provided", () => {
|
|
318
|
-
const server = new Server(
|
|
350
|
+
const server = new Server(settings, {
|
|
351
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
352
|
+
customId: "my-custom-id",
|
|
353
|
+
});
|
|
319
354
|
|
|
320
355
|
expect(server.providerId).toEqual("my-custom-id");
|
|
321
356
|
});
|
|
322
357
|
|
|
323
358
|
it("should fall back to URL-based providerId when no custom id", () => {
|
|
324
|
-
const server = new Server("http://127.0.0.1:8080");
|
|
359
|
+
const server = new Server(settings, { baseUrl: "http://127.0.0.1:8080" });
|
|
325
360
|
|
|
326
361
|
expect(server.providerId).toEqual(
|
|
327
362
|
`${PROVIDER_PREFIX}=http://127.0.0.1:8080`,
|
|
@@ -329,14 +364,19 @@ describe("Server with custom id", () => {
|
|
|
329
364
|
});
|
|
330
365
|
|
|
331
366
|
it("should try custom id first in getApiKey(), then fall back to URL-based", () => {
|
|
367
|
+
const server = new Server(settings, {
|
|
368
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
369
|
+
customId: "my-custom-id",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Server construction resolves the key eagerly (ApiClient built there);
|
|
373
|
+
// clear so the assertions below observe only the explicit getApiKey() call
|
|
332
374
|
vi.clearAllMocks();
|
|
333
375
|
// Mock: custom id returns placeholder (no key found)
|
|
334
376
|
mockReadStoredCredential
|
|
335
377
|
.mockReturnValueOnce(API_KEY_PLACEHOLDER)
|
|
336
378
|
.mockReturnValueOnce({ key: "fallback-key" });
|
|
337
379
|
|
|
338
|
-
const server = new Server("http://127.0.0.1:8080", "my-custom-id");
|
|
339
|
-
|
|
340
380
|
const result = server.getApiKey();
|
|
341
381
|
|
|
342
382
|
expect(result).toEqual("fallback-key");
|
|
@@ -350,7 +390,10 @@ describe("Server with custom id", () => {
|
|
|
350
390
|
it("should return custom id key directly when found", () => {
|
|
351
391
|
mockReadStoredCredential.mockReturnValue({ key: "custom-key" });
|
|
352
392
|
|
|
353
|
-
const server = new Server(
|
|
393
|
+
const server = new Server(settings, {
|
|
394
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
395
|
+
customId: "my-custom-id",
|
|
396
|
+
});
|
|
354
397
|
|
|
355
398
|
const result = server.getApiKey();
|
|
356
399
|
|
|
@@ -361,27 +404,26 @@ describe("Server with custom id", () => {
|
|
|
361
404
|
|
|
362
405
|
describe("Server with custom name", () => {
|
|
363
406
|
it("should use custom name as suffix in providerName", () => {
|
|
364
|
-
const server = new Server(
|
|
365
|
-
"http://127.0.0.1:8080",
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
);
|
|
407
|
+
const server = new Server(settings, {
|
|
408
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
409
|
+
customName: "Remote Server",
|
|
410
|
+
});
|
|
369
411
|
|
|
370
412
|
expect(server.providerName).toEqual(`Llama.cpp (Remote Server)`);
|
|
371
413
|
});
|
|
372
414
|
|
|
373
415
|
it("should fall back to URL-based name when no custom name", () => {
|
|
374
|
-
const server = new Server("http://127.0.0.1:8080");
|
|
416
|
+
const server = new Server(settings, { baseUrl: "http://127.0.0.1:8080" });
|
|
375
417
|
|
|
376
418
|
expect(server.providerName).toEqual(`Llama.cpp (http://127.0.0.1:8080)`);
|
|
377
419
|
});
|
|
378
420
|
|
|
379
421
|
it("should use custom name even with custom id", () => {
|
|
380
|
-
const server = new Server(
|
|
381
|
-
"http://127.0.0.1:8080",
|
|
382
|
-
"my-custom-id",
|
|
383
|
-
"Remote Server",
|
|
384
|
-
);
|
|
422
|
+
const server = new Server(settings, {
|
|
423
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
424
|
+
customId: "my-custom-id",
|
|
425
|
+
customName: "Remote Server",
|
|
426
|
+
});
|
|
385
427
|
|
|
386
428
|
expect(server.providerId).toEqual("my-custom-id");
|
|
387
429
|
expect(server.providerName).toEqual(`Llama.cpp (Remote Server)`);
|
|
@@ -409,6 +451,14 @@ describe("reactToModelSelect and autoloadOnMessage fallbacks", () => {
|
|
|
409
451
|
expect(result).toBe(false);
|
|
410
452
|
});
|
|
411
453
|
|
|
454
|
+
it("should return 'asc' when sortBy is not set", async () => {
|
|
455
|
+
const { settings } = await import("../src/managers/settings");
|
|
456
|
+
|
|
457
|
+
const result = settings.resolveSortBy();
|
|
458
|
+
|
|
459
|
+
expect(result).toBe("asc");
|
|
460
|
+
});
|
|
461
|
+
|
|
412
462
|
it("should return user values when set", async () => {
|
|
413
463
|
mockSettingsManager.getProjectSettings.mockReturnValue({
|
|
414
464
|
llamaSettings: {
|
|
@@ -634,3 +684,110 @@ describe("Thinking config resolution", () => {
|
|
|
634
684
|
);
|
|
635
685
|
});
|
|
636
686
|
});
|
|
687
|
+
|
|
688
|
+
describe("setLlamaSetting", () => {
|
|
689
|
+
const mockGetAgentDir = vi.mocked(getAgentDir);
|
|
690
|
+
const mockGetProjectSettings = vi.mocked(
|
|
691
|
+
mockSettingsManager.getProjectSettings,
|
|
692
|
+
);
|
|
693
|
+
const mockGetGlobalSettings = vi.mocked(
|
|
694
|
+
mockSettingsManager.getGlobalSettings,
|
|
695
|
+
);
|
|
696
|
+
const mockReload = vi.mocked(mockSettingsManager.reload);
|
|
697
|
+
const mockReadFile = vi.mocked(readFile);
|
|
698
|
+
const mockWriteFile = vi.mocked(writeFile);
|
|
699
|
+
const mockRename = vi.mocked(rename);
|
|
700
|
+
|
|
701
|
+
const SETTINGS_PATH = "/fake/agent/dir/settings.json";
|
|
702
|
+
|
|
703
|
+
beforeEach(() => {
|
|
704
|
+
vi.clearAllMocks();
|
|
705
|
+
mockGetAgentDir.mockReturnValue("/fake/agent/dir");
|
|
706
|
+
mockGetProjectSettings.mockReturnValue({});
|
|
707
|
+
mockGetGlobalSettings.mockReturnValue({});
|
|
708
|
+
mockReload.mockResolvedValue(undefined);
|
|
709
|
+
mockReadFile.mockResolvedValue("{}");
|
|
710
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
711
|
+
mockRename.mockResolvedValue(undefined);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("should write the merged llamaSettings key atomically and reload", async () => {
|
|
715
|
+
mockReadFile.mockResolvedValue(
|
|
716
|
+
JSON.stringify(
|
|
717
|
+
{ unrelated: true, llamaSettings: { reactToModelSelect: true } },
|
|
718
|
+
null,
|
|
719
|
+
2,
|
|
720
|
+
),
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
await settings.setLlamaSetting("sortBy", "desc");
|
|
724
|
+
|
|
725
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
|
726
|
+
const [tmpPath, written, encoding] = mockWriteFile.mock.calls[0];
|
|
727
|
+
expect(tmpPath).toBe(`${SETTINGS_PATH}.tmp`);
|
|
728
|
+
expect(encoding).toBe("utf-8");
|
|
729
|
+
const parsed = JSON.parse(written as string);
|
|
730
|
+
expect(parsed).toEqual({
|
|
731
|
+
unrelated: true,
|
|
732
|
+
llamaSettings: { reactToModelSelect: true, sortBy: "desc" },
|
|
733
|
+
});
|
|
734
|
+
expect(mockRename).toHaveBeenCalledWith(
|
|
735
|
+
`${SETTINGS_PATH}.tmp`,
|
|
736
|
+
SETTINGS_PATH,
|
|
737
|
+
);
|
|
738
|
+
expect(mockReload).toHaveBeenCalledTimes(1);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("should reflect the new value in resolvers immediately after the write", async () => {
|
|
742
|
+
mockSettingsManager.reload.mockImplementation(async () => {
|
|
743
|
+
mockGetGlobalSettings.mockReturnValue({
|
|
744
|
+
llamaSettings: { sortBy: "desc" },
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
await settings.setLlamaSetting("sortBy", "desc");
|
|
749
|
+
|
|
750
|
+
expect(settings.resolveSortBy()).toBe("desc");
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("should reject and skip reload when the write fails", async () => {
|
|
754
|
+
mockWriteFile.mockRejectedValue(new Error("ENOSPC: simulated"));
|
|
755
|
+
|
|
756
|
+
await expect(settings.setLlamaSetting("sortBy", "desc")).rejects.toThrow(
|
|
757
|
+
"ENOSPC",
|
|
758
|
+
);
|
|
759
|
+
expect(mockReload).not.toHaveBeenCalled();
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("should reject and leave the file untouched when the JSON is invalid", async () => {
|
|
763
|
+
mockReadFile.mockResolvedValue("{ broken");
|
|
764
|
+
|
|
765
|
+
await expect(settings.setLlamaSetting("sortBy", "desc")).rejects.toThrow(
|
|
766
|
+
/Cannot parse/,
|
|
767
|
+
);
|
|
768
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
769
|
+
expect(mockReload).not.toHaveBeenCalled();
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("should persist booleans and numbers with type fidelity", async () => {
|
|
773
|
+
await settings.setLlamaSetting("reactToModelSelect", false);
|
|
774
|
+
|
|
775
|
+
const [, firstWrite] = mockWriteFile.mock.calls[0];
|
|
776
|
+
expect(JSON.parse(firstWrite as string)).toEqual({
|
|
777
|
+
llamaSettings: { reactToModelSelect: false },
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
await settings.setLlamaSetting("pollingTimeout", 120000);
|
|
781
|
+
|
|
782
|
+
const [, secondWrite] = mockWriteFile.mock.calls[1];
|
|
783
|
+
expect(JSON.parse(secondWrite as string)).toEqual({
|
|
784
|
+
llamaSettings: { pollingTimeout: 120000 },
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("should still construct the manager without arguments", async () => {
|
|
789
|
+
const { LlamaSettingsManager } = await import("../src/managers/settings");
|
|
790
|
+
|
|
791
|
+
expect(() => new LlamaSettingsManager()).not.toThrow();
|
|
792
|
+
});
|
|
793
|
+
});
|