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.
- package/README.md +20 -9
- package/package.json +3 -3
- package/src/api/client.ts +25 -0
- package/src/constants.ts +8 -5
- package/src/enums/status.ts +0 -1
- package/src/interfaces/endpoints/models.ts +1 -1
- package/src/interfaces/settings.ts +3 -2
- package/src/interfaces/sortBy.ts +4 -0
- package/src/managers/command/models.ts +247 -0
- package/src/managers/command.ts +29 -459
- package/src/managers/events.ts +2 -2
- package/src/managers/server.ts +29 -5
- package/src/managers/settings.ts +12 -88
- package/src/models/baseModel.ts +27 -10
- package/src/models/legacyModel.ts +2 -2
- package/src/models/routerModel.ts +2 -2
- package/src/models/singleModel.ts +1 -1
- package/src/server.ts +35 -48
- package/src/sse/client.ts +113 -59
- package/src/sse/fetch.ts +43 -0
- package/src/sse/manager.ts +9 -27
- package/src/sse/types.ts +0 -4
- package/src/ui/dialog/base.ts +118 -0
- package/src/ui/dialog/confirm.ts +45 -0
- package/src/ui/dialog/factory.ts +111 -0
- package/src/ui/dialog/input.ts +63 -0
- package/src/ui/dialog/options.ts +23 -0
- package/src/ui/editors/editorOptions.ts +43 -0
- package/src/ui/editors/itemBuilder.ts +47 -0
- package/src/ui/editors/listEditor.ts +291 -0
- package/src/ui/editors/override/entry.ts +24 -0
- package/src/ui/editors/override/entryEditor.ts +166 -0
- package/src/ui/editors/override/fields.ts +294 -0
- package/src/ui/editors/override/handlers.ts +118 -0
- package/src/ui/editors/override/itemBuilder.ts +42 -0
- package/src/ui/editors/override/overrideList.ts +127 -0
- package/src/ui/editors/server/builder.ts +60 -0
- package/src/ui/editors/server/fields.ts +84 -0
- package/src/ui/editors/server/handlers.ts +32 -0
- package/src/ui/editors/server/itemBuilder.ts +68 -0
- package/src/ui/editors/server/serverEditor.ts +161 -0
- package/src/ui/editors/server/utils.ts +45 -0
- package/src/ui/editors/server/wizard.ts +110 -0
- package/src/ui/editors/settingField.ts +37 -0
- package/src/ui/editors/settingsListFactory.ts +33 -0
- package/src/ui/settings/index.ts +237 -0
- package/src/ui/strings.ts +8 -4
- package/src/utils/health.ts +48 -0
- package/src/utils/serverIds.ts +21 -0
- package/src/utils/settingsStore.ts +1 -1
- package/src/utils/urlResolver.ts +129 -0
- package/src/utils/urls.ts +33 -13
- package/tests/commandManager.test.ts +38 -7
- package/tests/dialog.test.ts +95 -2
- package/tests/health.test.ts +116 -0
- package/tests/legacyModel.test.ts +34 -28
- package/tests/overrides.test.ts +123 -68
- package/tests/routerModel.test.ts +67 -68
- package/tests/server.test.ts +47 -16
- package/tests/serverManager.test.ts +4 -4
- package/tests/settings.test.ts +10 -8
- package/tests/singleModel.test.ts +12 -12
- package/tests/sseManager.test.ts +6 -24
- package/src/ui/dialog.ts +0 -287
- package/src/ui/overrideEntryEditor.ts +0 -119
- package/src/ui/overrideSettingsList.ts +0 -682
- package/src/ui/serverListEditor.ts +0 -32
- package/src/ui/serverSettingsList.ts +0 -466
package/tests/dialog.test.ts
CHANGED
|
@@ -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
|
|
9
|
-
import {
|
|
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
|
|
38
|
+
const { input } = await model.toProviderConfig();
|
|
37
39
|
|
|
38
|
-
expect(
|
|
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
|
|
48
|
+
const { input } = await model.toProviderConfig();
|
|
46
49
|
|
|
47
|
-
expect(
|
|
50
|
+
expect(input).toEqual(["text"]);
|
|
48
51
|
});
|
|
49
52
|
|
|
50
53
|
it("should fall back to text-only when auth fails", async () => {
|
|
51
|
-
mockRpc.
|
|
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
|
|
63
|
+
const { input } = await model.toProviderConfig();
|
|
55
64
|
|
|
56
|
-
expect(
|
|
65
|
+
expect(input).toEqual(["text"]);
|
|
57
66
|
});
|
|
58
67
|
});
|
|
59
68
|
|
|
60
|
-
describe("LegacyModel
|
|
69
|
+
describe("LegacyModel context size (via toProviderConfig)", () => {
|
|
61
70
|
it("should use max_model_len when it is non-zero", async () => {
|
|
62
|
-
mockRpc.mockResolvedValueOnce({
|
|
63
|
-
mockRpc.mockResolvedValueOnce({
|
|
64
|
-
|
|
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
|
|
76
|
+
const { contextWindow } = await model.toProviderConfig();
|
|
69
77
|
|
|
70
|
-
expect(
|
|
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({
|
|
76
|
-
mockRpc.mockResolvedValueOnce({
|
|
77
|
-
|
|
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
|
|
88
|
+
const { contextWindow } = await model.toProviderConfig();
|
|
82
89
|
|
|
83
|
-
expect(
|
|
90
|
+
expect(contextWindow).toBe(4096);
|
|
84
91
|
});
|
|
85
92
|
|
|
86
|
-
it("should return
|
|
87
|
-
mockRpc.mockResolvedValueOnce({});
|
|
88
|
-
mockRpc.mockResolvedValueOnce({
|
|
89
|
-
|
|
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
|
|
99
|
+
const { contextWindow } = await model.toProviderConfig();
|
|
94
100
|
|
|
95
|
-
expect(
|
|
101
|
+
expect(contextWindow).toBe(FALLBACK_CTX);
|
|
96
102
|
});
|
|
97
103
|
});
|
package/tests/overrides.test.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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({
|
|
103
|
-
|
|
100
|
+
const model = createModel({
|
|
101
|
+
test: { capabilities: ["text", "image"], contextSize: 8192 },
|
|
102
|
+
});
|
|
103
|
+
const { input } = await model.toProviderConfig();
|
|
104
104
|
|
|
105
|
-
expect(
|
|
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
|
|
113
|
+
const { input } = await model.toProviderConfig();
|
|
114
114
|
|
|
115
|
-
expect(
|
|
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
|
|
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
|
|
127
|
+
const { contextWindow } = await model.toProviderConfig();
|
|
128
128
|
|
|
129
|
-
expect(
|
|
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
|
|
136
|
+
const { contextWindow } = await model.toProviderConfig();
|
|
137
137
|
|
|
138
|
-
expect(
|
|
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
|
|
145
|
+
const { contextWindow } = await model.toProviderConfig();
|
|
146
146
|
|
|
147
|
-
expect(
|
|
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
|
-
// ───
|
|
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
|
|
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(
|
|
266
|
-
expect(
|
|
267
|
-
expect(
|
|
268
|
-
expect(
|
|
269
|
-
expect(
|
|
270
|
-
expect(
|
|
271
|
-
expect(
|
|
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
|
|
278
|
+
const rendered = summary({
|
|
276
279
|
cost: { input: 0, output: 0.6 },
|
|
277
280
|
reasoning: undefined,
|
|
278
281
|
});
|
|
279
282
|
|
|
280
|
-
expect(
|
|
281
|
-
expect(
|
|
282
|
-
expect(
|
|
283
|
-
expect(
|
|
284
|
-
expect(
|
|
285
|
-
expect(
|
|
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(
|
|
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("
|
|
302
|
-
const next =
|
|
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("
|
|
310
|
-
const
|
|
311
|
-
|
|
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(
|
|
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("
|
|
319
|
-
const withTwo =
|
|
320
|
-
const next =
|
|
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("
|
|
325
|
-
|
|
326
|
-
expect(
|
|
327
|
-
expect(
|
|
328
|
-
expect(
|
|
329
|
-
expect(
|
|
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("
|
|
333
|
-
const
|
|
334
|
-
|
|
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("
|
|
338
|
-
const
|
|
339
|
-
|
|
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("
|
|
343
|
-
|
|
344
|
-
|
|
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("
|
|
348
|
-
|
|
349
|
-
|
|
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
|
});
|