pi-llama-cpp 0.10.0 → 0.12.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.
Files changed (41) hide show
  1. package/README.md +187 -10
  2. package/package.json +6 -6
  3. package/src/api/client.ts +76 -32
  4. package/src/constants.ts +15 -0
  5. package/src/index.ts +3 -5
  6. package/src/interfaces/events.ts +14 -3
  7. package/src/interfaces/server.ts +27 -0
  8. package/src/interfaces/settings.ts +76 -2
  9. package/src/managers/command.ts +369 -50
  10. package/src/managers/events.ts +28 -10
  11. package/src/managers/server.ts +90 -16
  12. package/src/managers/settings.ts +181 -44
  13. package/src/models/baseModel.ts +37 -15
  14. package/src/models/routerModel.ts +2 -1
  15. package/src/server.ts +103 -28
  16. package/src/sse/client.ts +28 -16
  17. package/src/sse/manager.ts +26 -13
  18. package/src/ui/dialog.ts +287 -0
  19. package/src/ui/overrideEntryEditor.ts +119 -0
  20. package/src/ui/overrideSettingsList.ts +682 -0
  21. package/src/ui/serverListEditor.ts +32 -0
  22. package/src/ui/serverSettingsList.ts +466 -0
  23. package/src/ui/strings.ts +127 -0
  24. package/src/utils/errors.ts +5 -0
  25. package/src/utils/settingsStore.ts +56 -0
  26. package/src/utils/urls.ts +16 -0
  27. package/tests/commandManager.test.ts +346 -11
  28. package/tests/dialog.test.ts +186 -0
  29. package/tests/events.test.ts +120 -88
  30. package/tests/legacyModel.test.ts +4 -19
  31. package/tests/mocks.ts +149 -32
  32. package/tests/overrides.test.ts +352 -0
  33. package/tests/server.test.ts +42 -40
  34. package/tests/serverManager.test.ts +264 -55
  35. package/tests/settings.test.ts +654 -51
  36. package/tests/settingsStore.test.ts +190 -0
  37. package/tests/singleModel.test.ts +32 -0
  38. package/tests/sseManager.test.ts +88 -11
  39. package/src/interfaces/auth.ts +0 -6
  40. package/src/utils/cache.ts +0 -39
  41. package/src/utils/mutex.ts +0 -24
@@ -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,
@@ -6,6 +7,7 @@ import {
6
7
  PROVIDER_PREFIX,
7
8
  SERVER_TIMEOUT,
8
9
  } from "../src/constants";
10
+ import type { ModelOverride } from "../src/interfaces/settings";
9
11
  import { settings } from "../src/managers/settings";
10
12
  import { Server } from "../src/server";
11
13
 
@@ -17,6 +19,7 @@ const mockSettingsManager = vi.hoisted(() => ({
17
19
  getGlobalSettings: vi.fn(),
18
20
  getDefaultThinkingLevel: vi.fn(),
19
21
  getThinkingBudgets: vi.fn(),
22
+ reload: vi.fn(),
20
23
  }));
21
24
 
22
25
  // Mock getAgentDir, readStoredCredential, and SettingsManager before importing resolver
@@ -31,10 +34,14 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
31
34
 
32
35
  vi.mock("node:fs/promises", () => ({
33
36
  readFile: vi.fn(),
37
+ writeFile: vi.fn(),
38
+ rename: vi.fn(),
39
+ access: vi.fn(),
34
40
  }));
35
41
 
36
42
  // Import mocked modules
37
43
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
44
+ import { access } from "node:fs/promises";
38
45
 
39
46
  describe("URL resolution fallback chain", () => {
40
47
  const mockGetAgentDir = vi.mocked(getAgentDir);
@@ -62,7 +69,7 @@ describe("URL resolution fallback chain", () => {
62
69
  // Ensure env var is not set (and not inherited from environment)
63
70
  delete process.env.LLAMA_SERVER_URL;
64
71
 
65
- const result = settings.resolveUrls();
72
+ const result = await settings.resolveUrls();
66
73
 
67
74
  expect(result).toEqual([LLAMA_SERVER_URL]);
68
75
  });
@@ -73,7 +80,7 @@ describe("URL resolution fallback chain", () => {
73
80
  });
74
81
  process.env.LLAMA_SERVER_URL = "http://env-url:8080";
75
82
 
76
- const result = settings.resolveUrls();
83
+ const result = await settings.resolveUrls();
77
84
 
78
85
  expect(result).toEqual(["http://env-url:8080"]);
79
86
  });
@@ -81,7 +88,7 @@ describe("URL resolution fallback chain", () => {
81
88
  it("should use env variable when no other config exists", async () => {
82
89
  process.env.LLAMA_SERVER_URL = "http://env-url:8080";
83
90
 
84
- const result = settings.resolveUrls();
91
+ const result = await settings.resolveUrls();
85
92
 
86
93
  expect(result).toEqual(["http://env-url:8080"]);
87
94
  });
@@ -91,7 +98,7 @@ describe("URL resolution fallback chain", () => {
91
98
  llamaServerUrl: "http://project:9999",
92
99
  });
93
100
 
94
- const result = settings.resolveUrls();
101
+ const result = await settings.resolveUrls();
95
102
 
96
103
  expect(result).toEqual(["http://project:9999"]);
97
104
  });
@@ -101,7 +108,7 @@ describe("URL resolution fallback chain", () => {
101
108
  llamaServerUrl: "http://global:8080",
102
109
  });
103
110
 
104
- const result = settings.resolveUrls();
111
+ const result = await settings.resolveUrls();
105
112
 
106
113
  expect(result).toEqual(["http://global:8080"]);
107
114
  });
@@ -109,7 +116,7 @@ describe("URL resolution fallback chain", () => {
109
116
  it("should strip trailing slashes from resolved URL", async () => {
110
117
  process.env.LLAMA_SERVER_URL = "http://localhost:8080/";
111
118
 
112
- const result = settings.resolveUrls();
119
+ const result = await settings.resolveUrls();
113
120
 
114
121
  expect(result).toEqual(["http://localhost:8080"]);
115
122
  });
@@ -117,8 +124,8 @@ describe("URL resolution fallback chain", () => {
117
124
  it("should cache the resolved URL on subsequent calls", async () => {
118
125
  process.env.LLAMA_SERVER_URL = "http://first:8080";
119
126
 
120
- const result1 = settings.resolveUrls();
121
- const result2 = settings.resolveUrls();
127
+ const result1 = await settings.resolveUrls();
128
+ const result2 = await settings.resolveUrls();
122
129
 
123
130
  expect(result1).toEqual(["http://first:8080"]);
124
131
  expect(result2).toEqual(["http://first:8080"]);
@@ -127,10 +134,38 @@ describe("URL resolution fallback chain", () => {
127
134
  it("should handle multiple URLs separated by semicolons", async () => {
128
135
  process.env.LLAMA_SERVER_URL = "http://first:8080;http://second:9090/";
129
136
 
130
- const result = settings.resolveUrls();
137
+ const result = await settings.resolveUrls();
131
138
 
132
139
  expect(result).toEqual(["http://first:8080", "http://second:9090"]);
133
140
  });
141
+
142
+ it("should drop env URLs without an http(s) scheme, warn, and fall through", async () => {
143
+ process.env.LLAMA_SERVER_URL = "127.0.0.1:8080";
144
+
145
+ const result = await settings.resolveUrls();
146
+
147
+ expect(result).toEqual([LLAMA_SERVER_URL]);
148
+ expect(settings.takeWarnings()).toEqual([
149
+ "Ignoring invalid server URL '127.0.0.1:8080' (needs http(s)://)",
150
+ ]);
151
+ expect(settings.takeWarnings()).toEqual([]); // drained
152
+ });
153
+
154
+ it("should drop server entries without an http(s) scheme and warn", async () => {
155
+ mockGetProjectSettings.mockReturnValue({
156
+ llamaSettings: {
157
+ servers: [{ url: "127.0.0.1:8080" }, { url: "http://good:8080/" }],
158
+ },
159
+ });
160
+
161
+ const result = await settings.resolveUrls();
162
+
163
+ expect(result).toEqual(["http://good:8080"]);
164
+ expect(settings.takeWarnings()).toEqual([
165
+ "Ignoring invalid server URL '127.0.0.1:8080' (needs http(s)://)",
166
+ ]);
167
+ expect(settings.takeWarnings()).toEqual([]); // drained
168
+ });
134
169
  });
135
170
 
136
171
  describe("llamaSettings.servers resolution", () => {
@@ -164,7 +199,7 @@ describe("llamaSettings.servers resolution", () => {
164
199
  },
165
200
  });
166
201
 
167
- const result = settings.resolveUrls();
202
+ const result = await settings.resolveUrls();
168
203
 
169
204
  expect(result).toEqual([
170
205
  "http://project-server:8080",
@@ -184,7 +219,7 @@ describe("llamaSettings.servers resolution", () => {
184
219
  },
185
220
  });
186
221
 
187
- const result = settings.resolveUrls();
222
+ const result = await settings.resolveUrls();
188
223
 
189
224
  expect(result).toEqual(["http://project:8080"]);
190
225
  });
@@ -196,7 +231,7 @@ describe("llamaSettings.servers resolution", () => {
196
231
  },
197
232
  });
198
233
 
199
- const result = settings.resolveUrls();
234
+ const result = await settings.resolveUrls();
200
235
 
201
236
  expect(result).toEqual(["http://global:8080"]);
202
237
  });
@@ -209,7 +244,7 @@ describe("llamaSettings.servers resolution", () => {
209
244
  },
210
245
  });
211
246
 
212
- const result = settings.resolveUrls();
247
+ const result = await settings.resolveUrls();
213
248
 
214
249
  expect(result).toEqual(["http://env:8080"]);
215
250
  });
@@ -221,7 +256,7 @@ describe("llamaSettings.servers resolution", () => {
221
256
  },
222
257
  });
223
258
 
224
- const result = settings.resolveUrls();
259
+ const result = await settings.resolveUrls();
225
260
 
226
261
  expect(result).toEqual(["http://server:9090"]);
227
262
  });
@@ -234,7 +269,7 @@ describe("llamaSettings.servers resolution", () => {
234
269
  },
235
270
  });
236
271
 
237
- const result = settings.resolveUrls();
272
+ const result = await settings.resolveUrls();
238
273
 
239
274
  expect(result).toEqual(["http://server:9090"]);
240
275
  });
@@ -247,7 +282,7 @@ describe("llamaSettings.servers resolution", () => {
247
282
  },
248
283
  });
249
284
 
250
- const result = settings.resolveUrls();
285
+ const result = await settings.resolveUrls();
251
286
 
252
287
  expect(result).toEqual(["http://legacy:8080"]);
253
288
  });
@@ -259,7 +294,7 @@ describe("llamaSettings.servers resolution", () => {
259
294
  },
260
295
  });
261
296
 
262
- const result = settings.resolveUrls();
297
+ const result = await settings.resolveUrls();
263
298
 
264
299
  expect(result).toEqual(["http://localhost:8080"]);
265
300
  });
@@ -315,13 +350,16 @@ describe("API key resolution", () => {
315
350
 
316
351
  describe("Server with custom id", () => {
317
352
  it("should use custom id as providerId when provided", () => {
318
- const server = new Server("http://127.0.0.1:8080", "my-custom-id");
353
+ const server = new Server(settings, {
354
+ baseUrl: "http://127.0.0.1:8080",
355
+ customId: "my-custom-id",
356
+ });
319
357
 
320
358
  expect(server.providerId).toEqual("my-custom-id");
321
359
  });
322
360
 
323
361
  it("should fall back to URL-based providerId when no custom id", () => {
324
- const server = new Server("http://127.0.0.1:8080");
362
+ const server = new Server(settings, { baseUrl: "http://127.0.0.1:8080" });
325
363
 
326
364
  expect(server.providerId).toEqual(
327
365
  `${PROVIDER_PREFIX}=http://127.0.0.1:8080`,
@@ -329,14 +367,19 @@ describe("Server with custom id", () => {
329
367
  });
330
368
 
331
369
  it("should try custom id first in getApiKey(), then fall back to URL-based", () => {
370
+ const server = new Server(settings, {
371
+ baseUrl: "http://127.0.0.1:8080",
372
+ customId: "my-custom-id",
373
+ });
374
+
375
+ // Server construction resolves the key eagerly (ApiClient built there);
376
+ // clear so the assertions below observe only the explicit getApiKey() call
332
377
  vi.clearAllMocks();
333
378
  // Mock: custom id returns placeholder (no key found)
334
379
  mockReadStoredCredential
335
380
  .mockReturnValueOnce(API_KEY_PLACEHOLDER)
336
381
  .mockReturnValueOnce({ key: "fallback-key" });
337
382
 
338
- const server = new Server("http://127.0.0.1:8080", "my-custom-id");
339
-
340
383
  const result = server.getApiKey();
341
384
 
342
385
  expect(result).toEqual("fallback-key");
@@ -350,7 +393,10 @@ describe("Server with custom id", () => {
350
393
  it("should return custom id key directly when found", () => {
351
394
  mockReadStoredCredential.mockReturnValue({ key: "custom-key" });
352
395
 
353
- const server = new Server("http://127.0.0.1:8080", "my-custom-id");
396
+ const server = new Server(settings, {
397
+ baseUrl: "http://127.0.0.1:8080",
398
+ customId: "my-custom-id",
399
+ });
354
400
 
355
401
  const result = server.getApiKey();
356
402
 
@@ -361,27 +407,26 @@ describe("Server with custom id", () => {
361
407
 
362
408
  describe("Server with custom name", () => {
363
409
  it("should use custom name as suffix in providerName", () => {
364
- const server = new Server(
365
- "http://127.0.0.1:8080",
366
- undefined,
367
- "Remote Server",
368
- );
410
+ const server = new Server(settings, {
411
+ baseUrl: "http://127.0.0.1:8080",
412
+ customName: "Remote Server",
413
+ });
369
414
 
370
415
  expect(server.providerName).toEqual(`Llama.cpp (Remote Server)`);
371
416
  });
372
417
 
373
418
  it("should fall back to URL-based name when no custom name", () => {
374
- const server = new Server("http://127.0.0.1:8080");
419
+ const server = new Server(settings, { baseUrl: "http://127.0.0.1:8080" });
375
420
 
376
421
  expect(server.providerName).toEqual(`Llama.cpp (http://127.0.0.1:8080)`);
377
422
  });
378
423
 
379
424
  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
- );
425
+ const server = new Server(settings, {
426
+ baseUrl: "http://127.0.0.1:8080",
427
+ customId: "my-custom-id",
428
+ customName: "Remote Server",
429
+ });
385
430
 
386
431
  expect(server.providerId).toEqual("my-custom-id");
387
432
  expect(server.providerName).toEqual(`Llama.cpp (Remote Server)`);
@@ -396,7 +441,7 @@ describe("reactToModelSelect and autoloadOnMessage fallbacks", () => {
396
441
  it("should return true when reactToModelSelect is not set", async () => {
397
442
  const { settings } = await import("../src/managers/settings");
398
443
 
399
- const result = settings.resolveReactToModelSelect();
444
+ const result = await settings.resolveReactToModelSelect();
400
445
 
401
446
  expect(result).toBe(true);
402
447
  });
@@ -404,11 +449,19 @@ describe("reactToModelSelect and autoloadOnMessage fallbacks", () => {
404
449
  it("should return false when autoloadOnMessage is not set", async () => {
405
450
  const { settings } = await import("../src/managers/settings");
406
451
 
407
- const result = settings.resolveAutoloadOnMessage();
452
+ const result = await settings.resolveAutoloadOnMessage();
408
453
 
409
454
  expect(result).toBe(false);
410
455
  });
411
456
 
457
+ it("should return 'asc' when sortBy is not set", async () => {
458
+ const { settings } = await import("../src/managers/settings");
459
+
460
+ const result = await settings.resolveSortBy();
461
+
462
+ expect(result).toBe("asc");
463
+ });
464
+
412
465
  it("should return user values when set", async () => {
413
466
  mockSettingsManager.getProjectSettings.mockReturnValue({
414
467
  llamaSettings: {
@@ -419,8 +472,8 @@ describe("reactToModelSelect and autoloadOnMessage fallbacks", () => {
419
472
 
420
473
  const { settings } = await import("../src/managers/settings");
421
474
 
422
- expect(settings.resolveReactToModelSelect()).toBe(false);
423
- expect(settings.resolveAutoloadOnMessage()).toBe(true);
475
+ expect(await settings.resolveReactToModelSelect()).toBe(false);
476
+ expect(await settings.resolveAutoloadOnMessage()).toBe(true);
424
477
  });
425
478
  });
426
479
 
@@ -445,7 +498,7 @@ describe("resolveServers", () => {
445
498
  mockGetGlobalSettings.mockReturnValue({});
446
499
  });
447
500
 
448
- it("should use llamaSettings.servers when configured", () => {
501
+ it("should use llamaSettings.servers when configured", async () => {
449
502
  mockGetProjectSettings.mockReturnValue({
450
503
  llamaSettings: {
451
504
  servers: [
@@ -454,7 +507,7 @@ describe("resolveServers", () => {
454
507
  },
455
508
  });
456
509
 
457
- const result = settings.resolveServers();
510
+ const result = await settings.resolveServers();
458
511
 
459
512
  expect(result).toHaveLength(1);
460
513
  expect(result[0].baseUrl).toBe("http://custom:8080");
@@ -464,20 +517,20 @@ describe("resolveServers", () => {
464
517
  it("should fall back to resolveUrls when servers is empty", async () => {
465
518
  process.env.LLAMA_SERVER_URL = "http://env-server:9090";
466
519
 
467
- const result = settings.resolveServers();
520
+ const result = await settings.resolveServers();
468
521
 
469
522
  expect(result).toHaveLength(1);
470
523
  expect(result[0].baseUrl).toBe("http://env-server:9090");
471
524
  });
472
525
 
473
- it("should fall back to default URL when no config exists", () => {
474
- const result = settings.resolveServers();
526
+ it("should fall back to default URL when no config exists", async () => {
527
+ const result = await settings.resolveServers();
475
528
 
476
529
  expect(result).toHaveLength(1);
477
530
  expect(result[0].baseUrl).toBe(LLAMA_SERVER_URL);
478
531
  });
479
532
 
480
- it("should apply id/name from llamaSettings.servers as overrides", () => {
533
+ it("should apply id/name from llamaSettings.servers as overrides", async () => {
481
534
  mockGetProjectSettings.mockReturnValue({
482
535
  llamaSettings: {
483
536
  servers: [
@@ -486,7 +539,7 @@ describe("resolveServers", () => {
486
539
  },
487
540
  });
488
541
 
489
- const result = settings.resolveServers();
542
+ const result = await settings.resolveServers();
490
543
 
491
544
  expect(result).toHaveLength(1);
492
545
  expect(result[0].baseUrl).toBe("http://127.0.0.1:8080");
@@ -494,7 +547,7 @@ describe("resolveServers", () => {
494
547
  expect(result[0].providerName).toBe(`Llama.cpp (Custom)`);
495
548
  });
496
549
 
497
- it("should handle multiple URLs with partial id/name overrides", () => {
550
+ it("should handle multiple URLs with partial id/name overrides", async () => {
498
551
  mockGetProjectSettings.mockReturnValue({
499
552
  llamaSettings: {
500
553
  servers: [{ url: "http://first:8080", id: "first-server" }],
@@ -502,7 +555,7 @@ describe("resolveServers", () => {
502
555
  });
503
556
  process.env.LLAMA_SERVER_URL = "http://first:8080;http://second:9090";
504
557
 
505
- const result = settings.resolveServers();
558
+ const result = await settings.resolveServers();
506
559
 
507
560
  expect(result).toHaveLength(2);
508
561
  expect(result[0].baseUrl).toBe("http://first:8080");
@@ -519,7 +572,7 @@ describe("resolveServers", () => {
519
572
  },
520
573
  });
521
574
 
522
- const result = settings.resolveServers();
575
+ const result = await settings.resolveServers();
523
576
 
524
577
  // env variable takes precedence via resolveUrls
525
578
  expect(result).toHaveLength(1);
@@ -535,7 +588,7 @@ describe("resolveTimeouts", () => {
535
588
  it("should return default timeouts when not configured", async () => {
536
589
  const { settings } = await import("../src/managers/settings");
537
590
 
538
- const result = settings.resolveTimeouts();
591
+ const result = await settings.resolveTimeouts();
539
592
 
540
593
  expect(result).toEqual({
541
594
  pollingTimeout: POLLING_TIMEOUT,
@@ -552,7 +605,7 @@ describe("resolveTimeouts", () => {
552
605
 
553
606
  const { settings } = await import("../src/managers/settings");
554
607
 
555
- const result = settings.resolveTimeouts();
608
+ const result = await settings.resolveTimeouts();
556
609
 
557
610
  expect(result.pollingTimeout).toBe(120000);
558
611
  expect(result.serverTimeout).toBe(SERVER_TIMEOUT);
@@ -567,7 +620,7 @@ describe("resolveTimeouts", () => {
567
620
 
568
621
  const { settings } = await import("../src/managers/settings");
569
622
 
570
- const result = settings.resolveTimeouts();
623
+ const result = await settings.resolveTimeouts();
571
624
 
572
625
  expect(result.pollingTimeout).toBe(POLLING_TIMEOUT);
573
626
  expect(result.serverTimeout).toBe(3000);
@@ -583,7 +636,7 @@ describe("resolveTimeouts", () => {
583
636
 
584
637
  const { settings } = await import("../src/managers/settings");
585
638
 
586
- const result = settings.resolveTimeouts();
639
+ const result = await settings.resolveTimeouts();
587
640
 
588
641
  expect(result).toEqual({
589
642
  pollingTimeout: 90000,
@@ -634,3 +687,553 @@ describe("Thinking config resolution", () => {
634
687
  );
635
688
  });
636
689
  });
690
+
691
+ describe("setLlamaSetting", () => {
692
+ const mockGetAgentDir = vi.mocked(getAgentDir);
693
+ const mockGetProjectSettings = vi.mocked(
694
+ mockSettingsManager.getProjectSettings,
695
+ );
696
+ const mockGetGlobalSettings = vi.mocked(
697
+ mockSettingsManager.getGlobalSettings,
698
+ );
699
+ const mockReload = vi.mocked(mockSettingsManager.reload);
700
+ const mockReadFile = vi.mocked(readFile);
701
+ const mockWriteFile = vi.mocked(writeFile);
702
+ const mockRename = vi.mocked(rename);
703
+ const mockAccess = vi.mocked(access);
704
+
705
+ const GLOBAL_SETTINGS_PATH = "/fake/agent/dir/settings.json";
706
+ const PROJECT_SETTINGS_PATH = "/fake/project/.pi/settings.json";
707
+ const FAKE_CWD = "/fake/project";
708
+
709
+ afterEach(() => {
710
+ vi.resetModules();
711
+ });
712
+
713
+ beforeEach(() => {
714
+ vi.clearAllMocks();
715
+ mockGetAgentDir.mockReturnValue("/fake/agent/dir");
716
+ vi.spyOn(process, "cwd").mockReturnValue(FAKE_CWD);
717
+ mockGetProjectSettings.mockReturnValue({});
718
+ mockGetGlobalSettings.mockReturnValue({});
719
+ mockReload.mockResolvedValue(undefined);
720
+ mockReadFile.mockResolvedValue("{}");
721
+ mockWriteFile.mockResolvedValue(undefined);
722
+ mockRename.mockResolvedValue(undefined);
723
+ });
724
+
725
+ it("should write to project settings when .pi/settings.json exists (auto scope)", async () => {
726
+ mockAccess.mockResolvedValue(undefined);
727
+ mockReadFile.mockResolvedValue("{}");
728
+
729
+ const { settings } = await import("../src/managers/settings");
730
+ await settings.setLlamaSetting("sortBy", "desc");
731
+
732
+ expect(mockWriteFile).toHaveBeenCalledWith(
733
+ `${PROJECT_SETTINGS_PATH}.tmp`,
734
+ expect.any(String),
735
+ "utf-8",
736
+ );
737
+ expect(mockRename).toHaveBeenCalledWith(
738
+ `${PROJECT_SETTINGS_PATH}.tmp`,
739
+ PROJECT_SETTINGS_PATH,
740
+ );
741
+ });
742
+
743
+ it("should write to global settings when .pi/settings.json does not exist (auto scope)", async () => {
744
+ mockAccess.mockRejectedValue(new Error("ENOENT"));
745
+ mockReadFile.mockResolvedValue("{}");
746
+
747
+ const { settings } = await import("../src/managers/settings");
748
+ await settings.setLlamaSetting("sortBy", "desc");
749
+
750
+ expect(mockWriteFile).toHaveBeenCalledWith(
751
+ `${GLOBAL_SETTINGS_PATH}.tmp`,
752
+ expect.any(String),
753
+ "utf-8",
754
+ );
755
+ expect(mockRename).toHaveBeenCalledWith(
756
+ `${GLOBAL_SETTINGS_PATH}.tmp`,
757
+ GLOBAL_SETTINGS_PATH,
758
+ );
759
+ });
760
+
761
+ it("should always write to global when scope is explicitly 'global'", async () => {
762
+ mockAccess.mockResolvedValue(undefined); // project exists but we override
763
+ mockReadFile.mockResolvedValue("{}");
764
+
765
+ const { settings } = await import("../src/managers/settings");
766
+ await settings.setLlamaSetting("sortBy", "desc", "global");
767
+
768
+ expect(mockWriteFile).toHaveBeenCalledWith(
769
+ `${GLOBAL_SETTINGS_PATH}.tmp`,
770
+ expect.any(String),
771
+ "utf-8",
772
+ );
773
+ });
774
+
775
+ it("should always write to project when scope is explicitly 'project'", async () => {
776
+ mockAccess.mockRejectedValue(new Error("ENOENT")); // project doesn't exist but we override
777
+ mockReadFile.mockResolvedValue("{}");
778
+
779
+ const { settings } = await import("../src/managers/settings");
780
+ await settings.setLlamaSetting("sortBy", "desc", "project");
781
+
782
+ expect(mockWriteFile).toHaveBeenCalledWith(
783
+ `${PROJECT_SETTINGS_PATH}.tmp`,
784
+ expect.any(String),
785
+ "utf-8",
786
+ );
787
+ });
788
+
789
+ it("should write the merged llamaSettings key atomically and reload", async () => {
790
+ mockAccess.mockRejectedValue(new Error("ENOENT")); // no project settings
791
+ mockReadFile.mockResolvedValue(
792
+ JSON.stringify(
793
+ { unrelated: true, llamaSettings: { reactToModelSelect: true } },
794
+ null,
795
+ 2,
796
+ ),
797
+ );
798
+
799
+ const { settings } = await import("../src/managers/settings");
800
+ await settings.setLlamaSetting("sortBy", "desc");
801
+
802
+ expect(mockWriteFile).toHaveBeenCalledTimes(1);
803
+ const [tmpPath, written, encoding] = mockWriteFile.mock.calls[0];
804
+ expect(tmpPath).toBe(`${GLOBAL_SETTINGS_PATH}.tmp`);
805
+ expect(encoding).toBe("utf-8");
806
+ const parsed = JSON.parse(written as string);
807
+ expect(parsed).toEqual({
808
+ unrelated: true,
809
+ llamaSettings: { reactToModelSelect: true, sortBy: "desc" },
810
+ });
811
+ expect(mockRename).toHaveBeenCalledWith(
812
+ `${GLOBAL_SETTINGS_PATH}.tmp`,
813
+ GLOBAL_SETTINGS_PATH,
814
+ );
815
+ expect(mockReload).toHaveBeenCalledTimes(1);
816
+ });
817
+
818
+ afterEach(() => {
819
+ vi.resetModules();
820
+ });
821
+
822
+ it("should reflect the new value in resolvers immediately after the write", async () => {
823
+ mockSettingsManager.reload.mockImplementation(async () => {
824
+ mockGetGlobalSettings.mockReturnValue({
825
+ llamaSettings: { sortBy: "desc" },
826
+ });
827
+ });
828
+
829
+ const { settings } = await import("../src/managers/settings");
830
+ await settings.setLlamaSetting("sortBy", "desc");
831
+
832
+ expect(await settings.resolveSortBy()).toBe("desc");
833
+ });
834
+
835
+ it("should reject and skip reload when the write fails", async () => {
836
+ mockAccess.mockRejectedValue(new Error("ENOENT"));
837
+ mockWriteFile.mockRejectedValue(new Error("ENOSPC: simulated"));
838
+
839
+ const { settings } = await import("../src/managers/settings");
840
+ await expect(settings.setLlamaSetting("sortBy", "desc")).rejects.toThrow(
841
+ "ENOSPC",
842
+ );
843
+ expect(mockReload).not.toHaveBeenCalled();
844
+ });
845
+
846
+ it("should reject and leave the file untouched when the JSON is invalid", async () => {
847
+ mockAccess.mockRejectedValue(new Error("ENOENT"));
848
+ mockReadFile.mockResolvedValue("{ broken");
849
+
850
+ const { settings } = await import("../src/managers/settings");
851
+ await expect(settings.setLlamaSetting("sortBy", "desc")).rejects.toThrow(
852
+ /Cannot parse/,
853
+ );
854
+ expect(mockWriteFile).not.toHaveBeenCalled();
855
+ expect(mockReload).not.toHaveBeenCalled();
856
+ });
857
+
858
+ it("should persist booleans and numbers with type fidelity", async () => {
859
+ mockAccess.mockRejectedValue(new Error("ENOENT"));
860
+ const { settings } = await import("../src/managers/settings");
861
+ await settings.setLlamaSetting("reactToModelSelect", false);
862
+
863
+ const [, firstWrite] = mockWriteFile.mock.calls[0];
864
+ expect(JSON.parse(firstWrite as string)).toEqual({
865
+ llamaSettings: { reactToModelSelect: false },
866
+ });
867
+
868
+ await settings.setLlamaSetting("pollingTimeout", 120000);
869
+
870
+ const [, secondWrite] = mockWriteFile.mock.calls[1];
871
+ expect(JSON.parse(secondWrite as string)).toEqual({
872
+ llamaSettings: { pollingTimeout: 120000 },
873
+ });
874
+ });
875
+
876
+ it("should still construct the manager without arguments", async () => {
877
+ const { LlamaSettingsManager } = await import("../src/managers/settings");
878
+
879
+ expect(() => new LlamaSettingsManager()).not.toThrow();
880
+ });
881
+ });
882
+
883
+ describe("resolveServerOverrides", () => {
884
+ const mockGetAgentDir = vi.mocked(getAgentDir);
885
+ const mockGetProjectSettings = vi.mocked(
886
+ mockSettingsManager.getProjectSettings,
887
+ );
888
+ const mockGetGlobalSettings = vi.mocked(
889
+ mockSettingsManager.getGlobalSettings,
890
+ );
891
+
892
+ afterEach(() => {
893
+ vi.resetModules();
894
+ });
895
+
896
+ beforeEach(() => {
897
+ vi.clearAllMocks();
898
+ mockGetAgentDir.mockReturnValue("/fake/agent/dir");
899
+ mockGetProjectSettings.mockReturnValue({});
900
+ mockGetGlobalSettings.mockReturnValue({});
901
+ });
902
+
903
+ it("should return overrides for a server that has them configured", async () => {
904
+ mockGetProjectSettings.mockReturnValue({
905
+ llamaSettings: {
906
+ servers: [
907
+ {
908
+ url: "http://127.0.0.1:8080",
909
+ overrides: {
910
+ "llama-3-8b": { cost: { input: 0.2, output: 0.6 } },
911
+ "llama-3-70b": {
912
+ cost: {
913
+ input: 0.1,
914
+ output: 0.3,
915
+ cacheRead: 0.01,
916
+ cacheWrite: 0.02,
917
+ },
918
+ },
919
+ },
920
+ },
921
+ ],
922
+ },
923
+ });
924
+
925
+ const result = await settings.resolveServerOverrides(
926
+ "http://127.0.0.1:8080",
927
+ );
928
+
929
+ expect(result).toEqual({
930
+ "llama-3-8b": { cost: { input: 0.2, output: 0.6 } },
931
+ "llama-3-70b": {
932
+ cost: {
933
+ input: 0.1,
934
+ output: 0.3,
935
+ cacheRead: 0.01,
936
+ cacheWrite: 0.02,
937
+ },
938
+ },
939
+ });
940
+ });
941
+
942
+ it("should return empty object for a server without overrides", async () => {
943
+ mockGetProjectSettings.mockReturnValue({
944
+ llamaSettings: {
945
+ servers: [{ url: "http://127.0.0.1:8080" }],
946
+ },
947
+ });
948
+
949
+ const result = await settings.resolveServerOverrides(
950
+ "http://127.0.0.1:8080",
951
+ );
952
+
953
+ expect(result).toEqual({});
954
+ });
955
+
956
+ it("should return empty object when server URL is not in config", async () => {
957
+ mockGetProjectSettings.mockReturnValue({
958
+ llamaSettings: {
959
+ servers: [{ url: "http://127.0.0.1:9090" }],
960
+ },
961
+ });
962
+
963
+ const result = await settings.resolveServerOverrides(
964
+ "http://127.0.0.1:8080",
965
+ );
966
+
967
+ expect(result).toEqual({});
968
+ });
969
+
970
+ it("should use global settings when no project config exists", async () => {
971
+ mockGetGlobalSettings.mockReturnValue({
972
+ llamaSettings: {
973
+ servers: [
974
+ {
975
+ url: "http://global:8080",
976
+ overrides: { "model-a": { cost: { input: 0.5 } } },
977
+ },
978
+ ],
979
+ },
980
+ });
981
+
982
+ const result = await settings.resolveServerOverrides("http://global:8080");
983
+
984
+ expect(result).toEqual({ "model-a": { cost: { input: 0.5 } } });
985
+ });
986
+
987
+ it("should prioritize project overrides over global overrides", async () => {
988
+ mockGetProjectSettings.mockReturnValue({
989
+ llamaSettings: {
990
+ servers: [
991
+ {
992
+ url: "http://shared:8080",
993
+ overrides: { "model-b": { cost: { input: 0.1, output: 0.2 } } },
994
+ },
995
+ ],
996
+ },
997
+ });
998
+ mockGetGlobalSettings.mockReturnValue({
999
+ llamaSettings: {
1000
+ servers: [
1001
+ {
1002
+ url: "http://shared:8080",
1003
+ overrides: { "model-b": { cost: { input: 0.5, output: 0.5 } } },
1004
+ },
1005
+ ],
1006
+ },
1007
+ });
1008
+
1009
+ const result = await settings.resolveServerOverrides("http://shared:8080");
1010
+
1011
+ expect(result).toEqual({
1012
+ "model-b": { cost: { input: 0.1, output: 0.2 } },
1013
+ });
1014
+ });
1015
+
1016
+ it("should return empty object when servers list is empty", async () => {
1017
+ mockGetProjectSettings.mockReturnValue({
1018
+ llamaSettings: { servers: [] },
1019
+ });
1020
+
1021
+ const result = await settings.resolveServerOverrides(
1022
+ "http://127.0.0.1:8080",
1023
+ );
1024
+
1025
+ expect(result).toEqual({});
1026
+ });
1027
+
1028
+ it("should return empty object when llamaSettings is missing", async () => {
1029
+ mockGetProjectSettings.mockReturnValue({});
1030
+
1031
+ const result = await settings.resolveServerOverrides(
1032
+ "http://127.0.0.1:8080",
1033
+ );
1034
+
1035
+ expect(result).toEqual({});
1036
+ });
1037
+
1038
+ it("should support partial override objects", async () => {
1039
+ mockGetProjectSettings.mockReturnValue({
1040
+ llamaSettings: {
1041
+ servers: [
1042
+ {
1043
+ url: "http://127.0.0.1:8080",
1044
+ overrides: { "partial-model": { cost: { input: 0.1 } } },
1045
+ },
1046
+ ],
1047
+ },
1048
+ });
1049
+
1050
+ const result = await settings.resolveServerOverrides(
1051
+ "http://127.0.0.1:8080",
1052
+ );
1053
+
1054
+ expect(result).toEqual({ "partial-model": { cost: { input: 0.1 } } });
1055
+ });
1056
+ });
1057
+
1058
+ describe("Server with overrides", () => {
1059
+ it("should store and expose resolved overrides", () => {
1060
+ const server = new Server(settings, {
1061
+ baseUrl: "http://127.0.0.1:8080",
1062
+ overrides: {
1063
+ "model-a": { cost: { input: 0.2, output: 0.6 } },
1064
+ "model-b": { cost: { input: 0.1, output: 0.3, cacheRead: 0.01 } },
1065
+ },
1066
+ });
1067
+
1068
+ expect(server.getOverrides()).toEqual({
1069
+ "model-a": { cost: { input: 0.2, output: 0.6 } },
1070
+ "model-b": { cost: { input: 0.1, output: 0.3, cacheRead: 0.01 } },
1071
+ });
1072
+ });
1073
+
1074
+ it("should return empty object when no overrides are provided", () => {
1075
+ const server = new Server(settings, {
1076
+ baseUrl: "http://127.0.0.1:8080",
1077
+ });
1078
+
1079
+ expect(server.getOverrides()).toEqual({});
1080
+ });
1081
+ });
1082
+
1083
+ describe("resolveServers passes overrides", () => {
1084
+ const mockGetAgentDir = vi.mocked(getAgentDir);
1085
+ const mockGetProjectSettings = vi.mocked(
1086
+ mockSettingsManager.getProjectSettings,
1087
+ );
1088
+ const mockGetGlobalSettings = vi.mocked(
1089
+ mockSettingsManager.getGlobalSettings,
1090
+ );
1091
+
1092
+ afterEach(() => {
1093
+ vi.resetModules();
1094
+ });
1095
+
1096
+ beforeEach(() => {
1097
+ vi.clearAllMocks();
1098
+ mockGetAgentDir.mockReturnValue("/fake/agent/dir");
1099
+ mockGetProjectSettings.mockReturnValue({});
1100
+ mockGetGlobalSettings.mockReturnValue({});
1101
+ });
1102
+
1103
+ it("should pass resolved overrides to Server instances", async () => {
1104
+ mockGetProjectSettings.mockReturnValue({
1105
+ llamaSettings: {
1106
+ servers: [
1107
+ {
1108
+ url: "http://overrides-server:8080",
1109
+ overrides: { "model-x": { cost: { input: 0.5, output: 1.0 } } },
1110
+ },
1111
+ {
1112
+ url: "http://no-overrides-server:9090",
1113
+ },
1114
+ ],
1115
+ },
1116
+ });
1117
+
1118
+ const result = await settings.resolveServers();
1119
+
1120
+ expect(result).toHaveLength(2);
1121
+ expect(result[0].getOverrides()).toEqual({
1122
+ "model-x": { cost: { input: 0.5, output: 1.0 } },
1123
+ });
1124
+ expect(result[1].getOverrides()).toEqual({});
1125
+ });
1126
+ });
1127
+
1128
+ describe("Server.findOverrideForModel", () => {
1129
+ function createServer(overrides: Record<string, ModelOverride>): Server {
1130
+ return new Server(settings as any, {
1131
+ baseUrl: "http://127.0.0.1:8080",
1132
+ overrides,
1133
+ });
1134
+ }
1135
+
1136
+ it("should return undefined when overrides is empty", () => {
1137
+ const server = createServer({});
1138
+ expect(server.findOverrideForModel("llama-3-8b")).toBeUndefined();
1139
+ });
1140
+
1141
+ it("should return undefined when no key matches", () => {
1142
+ const server = createServer({
1143
+ mistral: { cost: { input: 0.1 } },
1144
+ "gpt-4": { cost: { input: 0.3 } },
1145
+ });
1146
+ expect(server.findOverrideForModel("llama-3-8b")).toBeUndefined();
1147
+ });
1148
+
1149
+ it("should match exact ID", () => {
1150
+ const server = createServer({
1151
+ "llama-3-8b": { cost: { input: 0.2, output: 0.6 } },
1152
+ });
1153
+ expect(server.findOverrideForModel("llama-3-8b")).toEqual({
1154
+ cost: { input: 0.2, output: 0.6 },
1155
+ });
1156
+ });
1157
+
1158
+ it("should match prefix", () => {
1159
+ const server = createServer({
1160
+ llama: { cost: { input: 0.01, output: 0.02 } },
1161
+ });
1162
+ expect(server.findOverrideForModel("llama-3-8b")).toEqual({
1163
+ cost: { input: 0.01, output: 0.02 },
1164
+ });
1165
+ });
1166
+
1167
+ it("should prefer longest match (most specific)", () => {
1168
+ const server = createServer({
1169
+ llama: { cost: { input: 0.01, output: 0.02 } },
1170
+ "llama-3": { cost: { input: 0.05, output: 0.1 } },
1171
+ "llama-3-8b": { cost: { input: 0.2, output: 0.6 } },
1172
+ });
1173
+ expect(server.findOverrideForModel("llama-3-8b")).toEqual({
1174
+ cost: { input: 0.2, output: 0.6 },
1175
+ });
1176
+ });
1177
+
1178
+ it("should match the second-longest when exact match is absent", () => {
1179
+ const server = createServer({
1180
+ llama: { cost: { input: 0.01, output: 0.02 } },
1181
+ "llama-3": { cost: { input: 0.05, output: 0.1 } },
1182
+ "llama-3-8b": { cost: { input: 0.2, output: 0.6 } },
1183
+ });
1184
+ expect(server.findOverrideForModel("llama-3-70b")).toEqual({
1185
+ cost: { input: 0.05, output: 0.1 },
1186
+ });
1187
+ });
1188
+
1189
+ it("should skip empty keys", () => {
1190
+ const server = createServer({
1191
+ "": { cost: { input: 0.001 } },
1192
+ llama: { cost: { input: 0.01 } },
1193
+ });
1194
+ expect(server.findOverrideForModel("llama-3-8b")).toEqual({
1195
+ cost: { input: 0.01 },
1196
+ });
1197
+ });
1198
+
1199
+ it("should not match when model ID is shorter than key", () => {
1200
+ const server = createServer({
1201
+ "llama-3-8b": { cost: { input: 0.2 } },
1202
+ });
1203
+ expect(server.findOverrideForModel("llama")).toBeUndefined();
1204
+ });
1205
+
1206
+ it("should handle single matching key", () => {
1207
+ const server = createServer({
1208
+ qwen: { cost: { input: 0.1, output: 0.3 } },
1209
+ });
1210
+ expect(server.findOverrideForModel("qwen-3-8b")).toEqual({
1211
+ cost: { input: 0.1, output: 0.3 },
1212
+ });
1213
+ });
1214
+
1215
+ it("should handle overlapping but non-prefix matches", () => {
1216
+ const server = createServer({
1217
+ model: { cost: { input: 0.1 } },
1218
+ "model-a": { cost: { input: 0.2 } },
1219
+ });
1220
+ // "model" matches "model-a" and "model-b"
1221
+ // "model-a" matches only "model-a"
1222
+ expect(server.findOverrideForModel("model-a")).toEqual({
1223
+ cost: { input: 0.2 },
1224
+ });
1225
+ expect(server.findOverrideForModel("model-b")).toEqual({
1226
+ cost: { input: 0.1 },
1227
+ });
1228
+ });
1229
+
1230
+ it("should return overrides with capabilities and reasoning", () => {
1231
+ const server = createServer({
1232
+ qwen: { capabilities: ["text", "image"], reasoning: false },
1233
+ });
1234
+ expect(server.findOverrideForModel("qwen-3-8b")).toEqual({
1235
+ capabilities: ["text", "image"],
1236
+ reasoning: false,
1237
+ });
1238
+ });
1239
+ });