pi-llama-cpp 0.12.0 → 0.14.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 (68) hide show
  1. package/README.md +20 -9
  2. package/package.json +3 -3
  3. package/src/api/client.ts +25 -0
  4. package/src/constants.ts +8 -5
  5. package/src/enums/status.ts +0 -1
  6. package/src/interfaces/endpoints/models.ts +1 -1
  7. package/src/interfaces/settings.ts +3 -2
  8. package/src/interfaces/sortBy.ts +4 -0
  9. package/src/managers/command/models.ts +247 -0
  10. package/src/managers/command.ts +29 -459
  11. package/src/managers/events.ts +2 -2
  12. package/src/managers/server.ts +29 -5
  13. package/src/managers/settings.ts +12 -88
  14. package/src/models/baseModel.ts +27 -10
  15. package/src/models/legacyModel.ts +2 -2
  16. package/src/models/routerModel.ts +2 -2
  17. package/src/models/singleModel.ts +1 -1
  18. package/src/server.ts +35 -48
  19. package/src/sse/client.ts +113 -59
  20. package/src/sse/fetch.ts +43 -0
  21. package/src/sse/manager.ts +9 -27
  22. package/src/sse/types.ts +0 -4
  23. package/src/ui/dialog/base.ts +118 -0
  24. package/src/ui/dialog/confirm.ts +45 -0
  25. package/src/ui/dialog/factory.ts +111 -0
  26. package/src/ui/dialog/input.ts +63 -0
  27. package/src/ui/dialog/options.ts +23 -0
  28. package/src/ui/editors/editorOptions.ts +43 -0
  29. package/src/ui/editors/itemBuilder.ts +47 -0
  30. package/src/ui/editors/listEditor.ts +291 -0
  31. package/src/ui/editors/override/entry.ts +24 -0
  32. package/src/ui/editors/override/entryEditor.ts +166 -0
  33. package/src/ui/editors/override/fields.ts +294 -0
  34. package/src/ui/editors/override/handlers.ts +118 -0
  35. package/src/ui/editors/override/itemBuilder.ts +42 -0
  36. package/src/ui/editors/override/overrideList.ts +127 -0
  37. package/src/ui/editors/server/builder.ts +60 -0
  38. package/src/ui/editors/server/fields.ts +84 -0
  39. package/src/ui/editors/server/handlers.ts +32 -0
  40. package/src/ui/editors/server/itemBuilder.ts +68 -0
  41. package/src/ui/editors/server/serverEditor.ts +161 -0
  42. package/src/ui/editors/server/utils.ts +45 -0
  43. package/src/ui/editors/server/wizard.ts +110 -0
  44. package/src/ui/editors/settingField.ts +37 -0
  45. package/src/ui/editors/settingsListFactory.ts +33 -0
  46. package/src/ui/settings/index.ts +237 -0
  47. package/src/ui/strings.ts +8 -4
  48. package/src/utils/health.ts +48 -0
  49. package/src/utils/serverIds.ts +21 -0
  50. package/src/utils/settingsStore.ts +1 -1
  51. package/src/utils/urlResolver.ts +129 -0
  52. package/src/utils/urls.ts +33 -13
  53. package/tests/commandManager.test.ts +38 -7
  54. package/tests/dialog.test.ts +95 -2
  55. package/tests/health.test.ts +116 -0
  56. package/tests/legacyModel.test.ts +34 -28
  57. package/tests/overrides.test.ts +123 -68
  58. package/tests/routerModel.test.ts +67 -68
  59. package/tests/server.test.ts +47 -16
  60. package/tests/serverManager.test.ts +4 -4
  61. package/tests/settings.test.ts +10 -8
  62. package/tests/singleModel.test.ts +12 -12
  63. package/tests/sseManager.test.ts +6 -24
  64. package/src/ui/dialog.ts +0 -287
  65. package/src/ui/overrideEntryEditor.ts +0 -119
  66. package/src/ui/overrideSettingsList.ts +0 -682
  67. package/src/ui/serverListEditor.ts +0 -32
  68. package/src/ui/serverSettingsList.ts +0 -466
@@ -5,8 +5,10 @@ import {
5
5
  type TUI,
6
6
  } from "@earendil-works/pi-tui";
7
7
  import { beforeEach, describe, expect, it, vi } from "vitest";
8
- import { ConfirmDialog, InputDialog } from "../src/ui/dialog";
9
- import { ServerSettingsList } from "../src/ui/serverSettingsList";
8
+ import { ConfirmDialog } from "../src/ui/dialog/confirm";
9
+ import { InputDialog } from "../src/ui/dialog/input";
10
+ import { OverrideSettingsList } from "../src/ui/editors/override/overrideList";
11
+ import { ServerSettingsList } from "../src/ui/editors/server/serverEditor";
10
12
 
11
13
  beforeEach(() => {
12
14
  initTheme();
@@ -184,3 +186,94 @@ describe("ServerSettingsList add wizard", () => {
184
186
  expect(editor.render(80).join("\n")).toContain("(a) add server");
185
187
  });
186
188
  });
189
+
190
+ describe("OverrideEntryListEditor cost re-prefill", () => {
191
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
192
+
193
+ it("re-entering a cost field prefills the just-saved value", async () => {
194
+ const persist = vi.fn().mockResolvedValue(undefined);
195
+ const list = new OverrideSettingsList({
196
+ tui: createMockTui(),
197
+ theme: createMockTheme(),
198
+ keybindings: createKeybindings(),
199
+ servers: [
200
+ {
201
+ url: "http://x:1",
202
+ overrides: { "gpt-*": { cost: { input: 0.2 } } },
203
+ },
204
+ ] as never[],
205
+ persist,
206
+ done: vi.fn(),
207
+ onError: vi.fn(),
208
+ onChanged: vi.fn(),
209
+ });
210
+
211
+ const DOWN = "\x1b[B"; // tui.select.down
212
+ const CLEAR = "\x15"; // tui.editor.deleteToLineStart
213
+
214
+ list.handleInput(ENTER); // drill into the server row
215
+ list.handleInput(ENTER); // open the field submenu (cursor: pattern)
216
+ list.handleInput(DOWN); // cursor: cost.input
217
+ list.handleInput(ENTER); // open the input dialog (prefilled "0.2")
218
+ list.handleInput(CLEAR);
219
+ for (const ch of "0.5") list.handleInput(ch);
220
+ list.handleInput(ENTER); // commit
221
+ await flush();
222
+
223
+ // Re-enter the field: the prefill must be the saved value
224
+ list.handleInput(ENTER);
225
+ const rendered = list.render(80).join("\n");
226
+ expect(rendered).toContain("0.5");
227
+ expect(rendered).not.toContain("0.2");
228
+ });
229
+ });
230
+
231
+ describe("OverrideSettingsList escape navigation", () => {
232
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
233
+
234
+ const build = () => {
235
+ const done = vi.fn();
236
+ const list = new OverrideSettingsList({
237
+ tui: createMockTui(),
238
+ theme: createMockTheme(),
239
+ keybindings: createKeybindings(),
240
+ servers: [
241
+ {
242
+ url: "http://x:1",
243
+ overrides: { "gpt-*": { cost: { input: 0.2 } } },
244
+ },
245
+ ] as never[],
246
+ persist: vi.fn().mockResolvedValue(undefined),
247
+ done,
248
+ onError: vi.fn(),
249
+ onChanged: vi.fn(),
250
+ });
251
+ return { list, done };
252
+ };
253
+
254
+ it("Esc from the field submenu then the entry list lands on the server list", async () => {
255
+ const { list, done } = build();
256
+
257
+ list.handleInput(ENTER); // drill into the server row (entry list)
258
+ list.handleInput(ENTER); // open the field submenu
259
+ expect(list.render(80).join("\n")).not.toContain("http://x:1");
260
+
261
+ list.handleInput(ESC); // field submenu → entry list
262
+ await flush();
263
+ expect(done).not.toHaveBeenCalled();
264
+ expect(list.render(80).join("\n")).not.toContain("http://x:1");
265
+
266
+ list.handleInput(ESC); // entry list → server list
267
+ await flush();
268
+ // The dialog must stay open, showing the server row again
269
+ expect(done).not.toHaveBeenCalled();
270
+ expect(list.render(80).join("\n")).toContain("http://x:1");
271
+ });
272
+
273
+ it("Esc from the server list closes the dialog", async () => {
274
+ const { list, done } = build();
275
+
276
+ list.handleInput(ESC);
277
+ expect(done).toHaveBeenCalledTimes(1);
278
+ });
279
+ });
@@ -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
+ });
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
+ import { FALLBACK_CTX } from "../src/constants";
2
3
  import { Mode } from "../src/enums/mode";
3
4
  import { DataProperty } from "../src/interfaces/endpoints/models";
4
5
  import { LegacyModel } from "../src/models/legacyModel";
@@ -28,70 +29,75 @@ describe("LegacyModel mode", () => {
28
29
  });
29
30
  });
30
31
 
31
- describe("LegacyModel capabilities", () => {
32
+ describe("LegacyModel capabilities (via toProviderConfig)", () => {
32
33
  it("should detect image capability when multimodal is in capabilities", async () => {
33
34
  mockRpc.mockResolvedValueOnce({ modalities: { vision: true } });
35
+ mockRpc.mockResolvedValue({ n_ctx: 4096, data: [{ max_model_len: 8192 }] });
34
36
 
35
37
  const model = createModel();
36
- const capabilities = await model.getCapabilities();
38
+ const { input } = await model.toProviderConfig();
37
39
 
38
- expect(capabilities).toEqual(["text", "image"]);
40
+ expect(input).toEqual(["text", "image"]);
39
41
  });
40
42
 
41
43
  it("should detect text-only capability when multimodal is not in capabilities", async () => {
42
44
  mockRpc.mockResolvedValueOnce({ modalities: { vision: false } });
45
+ mockRpc.mockResolvedValue({ n_ctx: 4096, data: [{ max_model_len: 8192 }] });
43
46
 
44
47
  const model = createModel();
45
- const capabilities = await model.getCapabilities();
48
+ const { input } = await model.toProviderConfig();
46
49
 
47
- expect(capabilities).toEqual(["text"]);
50
+ expect(input).toEqual(["text"]);
48
51
  });
49
52
 
50
53
  it("should fall back to text-only when auth fails", async () => {
51
- mockRpc.mockRejectedValue(new Error("401 Unauthorized"));
54
+ mockRpc.mockRejectedValueOnce(new Error("401 Unauthorized")); // /props
55
+ mockRpc.mockRejectedValueOnce(new Error("401 Unauthorized")); // /v1/models in BaseModel's catch
56
+ mockRpc.mockResolvedValue({
57
+ n_ctx: 4096,
58
+ data: [{ max_model_len: 8192 }],
59
+ models: [{ capabilities: ["text"] }],
60
+ });
52
61
 
53
62
  const model = createModel();
54
- const capabilities = await model.getCapabilities();
63
+ const { input } = await model.toProviderConfig();
55
64
 
56
- expect(capabilities).toEqual(["text"]);
65
+ expect(input).toEqual(["text"]);
57
66
  });
58
67
  });
59
68
 
60
- describe("LegacyModel getContextSize", () => {
69
+ describe("LegacyModel context size (via toProviderConfig)", () => {
61
70
  it("should use max_model_len when it is non-zero", async () => {
62
- mockRpc.mockResolvedValueOnce({ n_ctx: 4096 });
63
- mockRpc.mockResolvedValueOnce({
64
- data: [{ max_model_len: 8192 }],
65
- });
71
+ mockRpc.mockResolvedValueOnce({ modalities: { vision: false } }); // capabilities: /props
72
+ mockRpc.mockResolvedValueOnce({ n_ctx: 4096 }); // /props
73
+ mockRpc.mockResolvedValueOnce({ data: [{ max_model_len: 8192 }] }); // /v1/models
66
74
 
67
75
  const model = createModel();
68
- const ctxSize = await model.getContextSize();
76
+ const { contextWindow } = await model.toProviderConfig();
69
77
 
70
- expect(ctxSize).toBe(8192);
78
+ expect(contextWindow).toBe(8192);
71
79
  expect(mockRpc).toHaveBeenCalledWith("/v1/models");
72
80
  });
73
81
 
74
82
  it("should fall back to n_ctx when max_model_len is 0", async () => {
75
- mockRpc.mockResolvedValueOnce({ n_ctx: 4096 });
76
- mockRpc.mockResolvedValueOnce({
77
- data: [{ max_model_len: 0 }],
78
- });
83
+ mockRpc.mockResolvedValueOnce({ modalities: { vision: false } }); // capabilities: /props
84
+ mockRpc.mockResolvedValueOnce({ n_ctx: 4096 }); // /props
85
+ mockRpc.mockResolvedValueOnce({ data: [{ max_model_len: 0 }] }); // /v1/models
79
86
 
80
87
  const model = createModel();
81
- const ctxSize = await model.getContextSize();
88
+ const { contextWindow } = await model.toProviderConfig();
82
89
 
83
- expect(ctxSize).toBe(4096);
90
+ expect(contextWindow).toBe(4096);
84
91
  });
85
92
 
86
- it("should return DEFAULT_CTX when both values are missing/null", async () => {
87
- mockRpc.mockResolvedValueOnce({});
88
- mockRpc.mockResolvedValueOnce({
89
- data: [{ max_model_len: null }],
90
- });
93
+ it("should return FALLBACK_CTX when both values are missing/null", async () => {
94
+ mockRpc.mockResolvedValueOnce({ modalities: { vision: false } }); // capabilities: /props
95
+ mockRpc.mockResolvedValueOnce({}); // /props
96
+ mockRpc.mockResolvedValueOnce({ data: [{ max_model_len: null }] }); // /v1/models
91
97
 
92
98
  const model = createModel();
93
- const ctxSize = await model.getContextSize();
99
+ const { contextWindow } = await model.toProviderConfig();
94
100
 
95
- expect(ctxSize).toBe(128000);
101
+ expect(contextWindow).toBe(FALLBACK_CTX);
96
102
  });
97
103
  });
@@ -3,14 +3,12 @@ import { FALLBACK_CTX } from "../src/constants";
3
3
  import type { LlamaServer, ModelOverride } from "../src/interfaces/settings";
4
4
  import { SingleModel } from "../src/models/singleModel";
5
5
  import { Server } from "../src/server";
6
+ import { OverrideEntry } from "../src/ui/editors/override/entry";
6
7
  import {
7
- addOverrideEntry,
8
- applyCostFieldValue,
9
- formatOverrideSummary,
10
- parseCostValue,
11
- removeOverrideEntry,
12
- updateOverrideEntry,
13
- } from "../src/ui/overrideEntryEditor";
8
+ OverrideFields,
9
+ OverrideSummary,
10
+ } from "../src/ui/editors/override/fields";
11
+ import { OverrideEntryMutator } from "../src/ui/editors/override/handlers";
14
12
  import { createMockServer, mockRpc } from "./mocks";
15
13
 
16
14
  beforeEach(() => {
@@ -93,16 +91,18 @@ describe("BaseModel reasoning override", () => {
93
91
 
94
92
  // ─── capabilities override ────────────────────────────────────────────────
95
93
 
96
- describe("BaseModel capabilities override", () => {
94
+ describe("BaseModel capabilities override (via toProviderConfig)", () => {
97
95
  it("should fully replace detection without contacting the server", async () => {
98
96
  mockRpc.mockImplementation(() => {
99
97
  throw new Error("override must not trigger a fetch");
100
98
  });
101
99
 
102
- const model = createModel({ test: { capabilities: ["text", "image"] } });
103
- const capabilities = await model.getCapabilities();
100
+ const model = createModel({
101
+ test: { capabilities: ["text", "image"], contextSize: 8192 },
102
+ });
103
+ const { input } = await model.toProviderConfig();
104
104
 
105
- expect(capabilities).toEqual(["text", "image"]);
105
+ expect(input).toEqual(["text", "image"]);
106
106
  expect(mockRpc).not.toHaveBeenCalled();
107
107
  });
108
108
 
@@ -110,41 +110,41 @@ describe("BaseModel capabilities override", () => {
110
110
  mockDetection(8192);
111
111
 
112
112
  const model = createModel({ other: { capabilities: ["text"] } });
113
- const capabilities = await model.getCapabilities();
113
+ const { input } = await model.toProviderConfig();
114
114
 
115
- expect(capabilities).toEqual(["text"]);
115
+ expect(input).toEqual(["text"]);
116
116
  expect(mockRpc).toHaveBeenCalled();
117
117
  });
118
118
  });
119
119
 
120
120
  // ─── contextSize override ─────────────────────────────────────────────────
121
121
 
122
- describe("BaseModel.getContextSize with contextSize override", () => {
122
+ describe("BaseModel contextSize override (via toProviderConfig)", () => {
123
123
  it("should return the overridden context size instead of the detected one", async () => {
124
124
  mockDetection(8192);
125
125
 
126
126
  const model = createModel({ test: { contextSize: 32768 } });
127
- const ctxSize = await model.getContextSize();
127
+ const { contextWindow } = await model.toProviderConfig();
128
128
 
129
- expect(ctxSize).toBe(32768);
129
+ expect(contextWindow).toBe(32768);
130
130
  });
131
131
 
132
132
  it("should treat a stored 0 as absent and autodetect", async () => {
133
133
  mockDetection(8192);
134
134
 
135
135
  const model = createModel({ test: { contextSize: 0 } });
136
- const ctxSize = await model.getContextSize();
136
+ const { contextWindow } = await model.toProviderConfig();
137
137
 
138
- expect(ctxSize).toBe(8192);
138
+ expect(contextWindow).toBe(8192);
139
139
  });
140
140
 
141
141
  it("should autodetect when no override matches", async () => {
142
142
  mockDetection(8192);
143
143
 
144
144
  const model = createModel({ other: { contextSize: 32768 } });
145
- const ctxSize = await model.getContextSize();
145
+ const { contextWindow } = await model.toProviderConfig();
146
146
 
147
- expect(ctxSize).toBe(8192);
147
+ expect(contextWindow).toBe(8192);
148
148
  });
149
149
  });
150
150
 
@@ -250,11 +250,14 @@ describe("toProviderConfig overrides", () => {
250
250
  });
251
251
  });
252
252
 
253
- // ─── formatOverrideSummary ────────────────────────────────────────────────
253
+ // ─── OverrideEntry.summary ────────────────────────────────────────────────────
254
+
255
+ describe("OverrideSummary.of", () => {
256
+ const summary = (override: ModelOverride): string =>
257
+ OverrideSummary.of(override);
254
258
 
255
- describe("formatOverrideSummary", () => {
256
259
  it("should render every configured field", () => {
257
- const summary = formatOverrideSummary({
260
+ const rendered = summary({
258
261
  cost: { input: 0.2, output: 0.6, cacheRead: 0.01 },
259
262
  capabilities: ["text", "image"],
260
263
  reasoning: false,
@@ -262,31 +265,31 @@ describe("formatOverrideSummary", () => {
262
265
  maxTokens: 4096,
263
266
  });
264
267
 
265
- expect(summary).toContain("input: $0.2");
266
- expect(summary).toContain("output: $0.6");
267
- expect(summary).toContain("cache read: $0.01");
268
- expect(summary).toContain("capabilities: text,image");
269
- expect(summary).toContain("reasoning: false");
270
- expect(summary).toContain("contextSize: 32768");
271
- expect(summary).toContain("maxTokens: 4096");
268
+ expect(rendered).toContain("input: $0.2");
269
+ expect(rendered).toContain("output: $0.6");
270
+ expect(rendered).toContain("cache read: $0.01");
271
+ expect(rendered).toContain("capabilities: text,image");
272
+ expect(rendered).toContain("reasoning: false");
273
+ expect(rendered).toContain("contextSize: 32768");
274
+ expect(rendered).toContain("maxTokens: 4096");
272
275
  });
273
276
 
274
277
  it("should omit zero, undefined and empty fields", () => {
275
- const summary = formatOverrideSummary({
278
+ const rendered = summary({
276
279
  cost: { input: 0, output: 0.6 },
277
280
  reasoning: undefined,
278
281
  });
279
282
 
280
- expect(summary).not.toContain("input:");
281
- expect(summary).toContain("output: $0.6");
282
- expect(summary).not.toContain("capabilities");
283
- expect(summary).not.toContain("reasoning");
284
- expect(summary).not.toContain("contextSize");
285
- expect(summary).not.toContain("maxTokens");
283
+ expect(rendered).not.toContain("input:");
284
+ expect(rendered).toContain("output: $0.6");
285
+ expect(rendered).not.toContain("capabilities");
286
+ expect(rendered).not.toContain("reasoning");
287
+ expect(rendered).not.toContain("contextSize");
288
+ expect(rendered).not.toContain("maxTokens");
286
289
  });
287
290
 
288
291
  it("should render an em dash for an empty override", () => {
289
- expect(formatOverrideSummary({})).toBe("—");
292
+ expect(summary({})).toBe("—");
290
293
  });
291
294
  });
292
295
 
@@ -298,55 +301,107 @@ describe("override entry list helpers", () => {
298
301
  { url: "http://b" },
299
302
  ] as LlamaServer[];
300
303
 
301
- it("addOverrideEntry should append a pattern → override entry", () => {
302
- const next = addOverrideEntry(servers, 0, "a-2", { contextSize: 4096 });
304
+ it("addEntry should append a pattern → override entry", () => {
305
+ const next = new OverrideEntryMutator(servers, 0).addEntry("a-2", {
306
+ contextSize: 4096,
307
+ });
303
308
  expect(next[0].overrides).toEqual({
304
309
  "a-1": { reasoning: false },
305
310
  "a-2": { contextSize: 4096 },
306
311
  });
307
312
  });
308
313
 
309
- it("updateOverrideEntry should replace in place without reordering", () => {
310
- const next = updateOverrideEntry(servers, 0, 0, "a-renamed", {
311
- reasoning: true,
314
+ it("pattern renames should replace in place without reordering", () => {
315
+ const mutator = new OverrideEntryMutator(servers, 0);
316
+ const result = mutator.applyFieldChange(0, "pattern", "a-renamed");
317
+ expect(result).not.toBeNull();
318
+ expect(Object.keys(result!.next[0].overrides!)).toEqual(["a-renamed"]);
319
+ expect(result!.next[0].overrides!["a-renamed"]).toEqual({
320
+ reasoning: false,
312
321
  });
313
- expect(Object.keys(next[0].overrides!)).toEqual(["a-renamed"]);
314
- expect(next[0].overrides!["a-renamed"]).toEqual({ reasoning: true });
315
- expect(next[1]).toBe(servers[1]);
322
+ expect(result!.next[1]).toBe(servers[1]);
316
323
  });
317
324
 
318
- it("removeOverrideEntry should drop only the targeted entry", () => {
319
- const withTwo = addOverrideEntry(servers, 0, "a-2", {});
320
- const next = removeOverrideEntry(withTwo, 0, 0);
325
+ it("removeEntry should drop only the targeted entry", () => {
326
+ const withTwo = new OverrideEntryMutator(servers, 0).addEntry("a-2");
327
+ const next = new OverrideEntryMutator(withTwo, 0).removeEntry(0);
321
328
  expect(Object.keys(next[0].overrides!)).toEqual(["a-2"]);
322
329
  });
323
330
 
324
- it("parseCostValue should accept blank, non-negative numbers; reject the rest", () => {
325
- expect(parseCostValue("")).toBe(0);
326
- expect(parseCostValue(" 0.5 ")).toBe(0.5);
327
- expect(parseCostValue("-1")).toBeNull();
328
- expect(parseCostValue("abc")).toBeNull();
329
- expect(parseCostValue("Infinity")).toBeNull();
331
+ it("CostField.validate should accept blank, non-negative numbers; reject the rest", () => {
332
+ const cost = OverrideFields.byId("cost.input");
333
+ expect(cost.validate("")).toBe("0");
334
+ expect(cost.validate(" 0.5 ")).toBe("0.5");
335
+ expect(cost.validate("-1")).toBeNull();
336
+ expect(cost.validate("abc")).toBeNull();
337
+ expect(cost.validate("Infinity")).toBeNull();
330
338
  });
331
339
 
332
- it("applyCostFieldValue should set a positive value, keeping other fields", () => {
333
- const next = applyCostFieldValue({ input: 0.2 }, "output", 0.6);
334
- expect(next).toEqual({ input: 0.2, output: 0.6 });
340
+ it("CostField.apply should set a positive value, keeping other fields", () => {
341
+ const override: ModelOverride = { cost: { input: 0.2 } };
342
+ OverrideFields.byId("cost.output").apply(override, "0.6");
343
+ expect(override.cost).toEqual({ input: 0.2, output: 0.6 });
335
344
  });
336
345
 
337
- it("applyCostFieldValue should remove the field on zero", () => {
338
- const next = applyCostFieldValue({ input: 0.2, output: 0.6 }, "input", 0);
339
- expect(next).toEqual({ output: 0.6 });
346
+ it("CostField.apply should remove the field on zero", () => {
347
+ const override: ModelOverride = { cost: { input: 0.2, output: 0.6 } };
348
+ OverrideFields.byId("cost.input").apply(override, "0");
349
+ expect(override.cost).toEqual({ output: 0.6 });
340
350
  });
341
351
 
342
- it("applyCostFieldValue should return undefined when the last field is zeroed", () => {
343
- expect(applyCostFieldValue({ input: 0.2 }, "input", 0)).toBeUndefined();
344
- expect(applyCostFieldValue(undefined, "input", 0)).toBeUndefined();
352
+ it("CostField.apply should drop the whole cost object when the last field is zeroed", () => {
353
+ const single: ModelOverride = { cost: { input: 0.2 } };
354
+ OverrideFields.byId("cost.input").apply(single, "0");
355
+ expect(single.cost).toBeUndefined();
356
+
357
+ const empty: ModelOverride = {};
358
+ OverrideFields.byId("cost.input").apply(empty, "0");
359
+ expect(empty.cost).toBeUndefined();
345
360
  });
346
361
 
347
- it("applyCostFieldValue should treat an empty override as unset cost", () => {
348
- expect(applyCostFieldValue(undefined, "input", 0.5)).toEqual({
349
- input: 0.5,
350
- });
362
+ it("CostField.apply should treat an empty override as unset cost", () => {
363
+ const override: ModelOverride = {};
364
+ OverrideFields.byId("cost.input").apply(override, "0.5");
365
+ expect(override.cost).toEqual({ input: 0.5 });
366
+ });
367
+ });
368
+
369
+ describe("OverrideField.displayValue", () => {
370
+ const display = (id: string, override: ModelOverride): string =>
371
+ OverrideFields.byId(id).displayValue(new OverrideEntry("gpt-*", override));
372
+
373
+ it("formats cost fields, defaulting to 0", () => {
374
+ expect(display("cost.input", {})).toBe("0");
375
+ expect(display("cost.input", { cost: { input: 0.2 } })).toBe("0.2");
376
+ expect(display("cost.cacheWrite", {})).toBe("0");
377
+ });
378
+
379
+ it("formats capabilities and reasoning labels", () => {
380
+ expect(display("capabilities", {})).toBe("text");
381
+ expect(display("capabilities", { capabilities: ["text", "image"] })).toBe(
382
+ "text | image",
383
+ );
384
+ expect(display("reasoning", {})).toBe("true");
385
+ expect(display("reasoning", { reasoning: false })).toBe("false");
386
+ });
387
+
388
+ it("formats maxTokens/contextSize, defaulting to 0", () => {
389
+ expect(display("maxTokens", {})).toBe("0");
390
+ expect(display("maxTokens", { maxTokens: 4096 })).toBe("4096");
391
+ expect(display("contextSize", { contextSize: 8192 })).toBe("8192");
392
+ });
393
+
394
+ it("returns the entry's pattern for the pattern field", () => {
395
+ expect(
396
+ OverrideFields.byId("pattern").displayValue(
397
+ new OverrideEntry("gpt-*", {}),
398
+ ),
399
+ ).toBe("gpt-*");
400
+ });
401
+
402
+ it("throws for unknown fields", () => {
403
+ expect(() => OverrideFields.byId("other")).toThrow(
404
+ "Unknown override field: other",
405
+ );
351
406
  });
352
407
  });