pi-llama-cpp 0.5.1 → 0.7.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.
@@ -1,153 +1,202 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { PROVIDER_ID, PROVIDER_NAME } from "../src/constants";
3
- import { CommandManager } from "../src/manager";
4
-
5
- // Mock modules at top level (vi.mock is hoisted)
6
- vi.mock("../src/tools/retriever", () => ({
7
- isServerReady: vi.fn(),
8
- listModels: vi.fn(),
9
- }));
10
-
11
- vi.mock("../src/tools/resolver", () => ({
12
- resolveUrl: vi.fn(),
13
- resolveApiKey: vi.fn(),
14
- }));
15
-
16
- // Import mocked functions after vi.mock
17
- import { resolveApiKey, resolveUrl } from "../src/tools/resolver";
18
- import { isServerReady, listModels } from "../src/tools/retriever";
19
-
20
- const mockPi = {
21
- registerProvider: vi.fn(),
22
- };
2
+ import { Action } from "../src/enums/action";
3
+ import { CommandManager } from "../src/managers/command";
4
+ import { ServerManager } from "../src/managers/server";
5
+ import {
6
+ createMockCtx,
7
+ createMockModel,
8
+ createMockPi,
9
+ createMockServer,
10
+ mockRpc,
11
+ } from "./mocks";
23
12
 
24
13
  beforeEach(() => {
25
14
  vi.clearAllMocks();
26
- (resolveUrl as any).mockResolvedValue("http://127.0.0.1:8080");
27
- (resolveApiKey as any).mockResolvedValue("test-key");
15
+ mockRpc.mockResolvedValue({ data: [] });
28
16
  });
29
17
 
30
18
  describe("CommandManager", () => {
31
- it("should register empty models when server is not ready", async () => {
32
- (isServerReady as any).mockResolvedValue(false);
33
-
34
- const manager = new CommandManager(mockPi as any);
35
- await manager.initialize();
36
-
37
- expect(mockPi.registerProvider).toHaveBeenCalledWith(PROVIDER_ID, {
38
- name: PROVIDER_NAME,
39
- baseUrl: "http://127.0.0.1:8080",
40
- api: "openai-completions",
41
- apiKey: "test-key",
42
- models: [],
43
- });
19
+ let serverManager: ServerManager;
20
+ let commandManager: CommandManager;
21
+ let mockPi: ReturnType<typeof createMockPi>;
22
+
23
+ beforeEach(() => {
24
+ mockPi = createMockPi();
25
+ serverManager = new ServerManager([]);
26
+ commandManager = new CommandManager(serverManager);
44
27
  });
45
28
 
46
- it("should update and register models when server is ready", async () => {
47
- const mockModel = {
48
- name: "test-model",
49
- id: "test-model",
50
- toProviderConfig: vi
51
- .fn()
52
- .mockResolvedValue({ id: "test-model", maxTokens: 32000 }),
53
- };
54
- (isServerReady as any).mockResolvedValue(true);
55
- (listModels as any).mockResolvedValue([mockModel]);
56
-
57
- const manager = new CommandManager(mockPi as any);
58
- await manager.initialize();
59
-
60
- expect(resolveUrl).toHaveBeenCalledWith(expect.any(String));
61
- expect(listModels).toHaveBeenCalled();
62
- expect(mockPi.registerProvider).toHaveBeenCalledWith(PROVIDER_ID, {
63
- name: PROVIDER_NAME,
64
- baseUrl: "http://127.0.0.1:8080",
65
- api: "openai-completions",
66
- apiKey: "test-key",
67
- models: [{ id: "test-model", maxTokens: 32000 }],
29
+ describe("getArgumentCompletions", () => {
30
+ it("should provide completions for /models", () => {
31
+ const completions = commandManager.getArgumentCompletions("");
32
+ expect(completions).toHaveLength(2);
33
+ expect(completions?.map((c) => c.value)).toEqual(["info", "unload"]);
68
34
  });
69
- });
70
35
 
71
- it("should call notFoundCommand when server is not ready in run()", async () => {
72
- (isServerReady as any).mockResolvedValue(false);
73
-
74
- const manager = new CommandManager(mockPi as any);
75
- await manager.run("", { ui: { notify: vi.fn() } } as any);
36
+ it("should filter completions by prefix", () => {
37
+ const completions = commandManager.getArgumentCompletions("u");
38
+ expect(completions).toHaveLength(1);
39
+ expect(completions?.[0].value).toBe("unload");
40
+ });
76
41
 
77
- expect(mockPi.registerProvider).not.toHaveBeenCalled();
42
+ it("should return null when no completions match", () => {
43
+ const completions = commandManager.getArgumentCompletions("zzz");
44
+ expect(completions).toBeNull();
45
+ });
78
46
  });
79
47
 
80
- it("should show info for all models when args is 'info'", async () => {
81
- const mockModel = {
82
- name: "test-model",
83
- id: "test-model",
84
- getInfo: vi.fn().mockResolvedValue("Model info for test-model"),
85
- toProviderConfig: vi.fn().mockResolvedValue({ id: "test-model" }),
86
- };
87
- (isServerReady as any).mockResolvedValue(true);
88
- (listModels as any).mockResolvedValue([mockModel]);
89
-
90
- const notifyFn = vi.fn();
91
- const manager = new CommandManager(mockPi as any);
92
- await manager.initialize();
93
- await manager.run("info", {
94
- ui: { notify: notifyFn, theme: { fg: (_c: string, t: string) => t } },
95
- } as any);
96
-
97
- expect(notifyFn).toHaveBeenCalledWith("Model info for test-model", "info");
98
- // Called once in initialize() and once in run() to refresh the model list
99
- expect(listModels).toHaveBeenCalledTimes(2);
100
- });
48
+ describe("handleCommand", () => {
49
+ it("should unload all models when args is 'unload'", async () => {
50
+ const model1 = createMockModel("model-1");
51
+ const model2 = createMockModel("model-2");
52
+ const server = createMockServer({
53
+ baseUrl: "http://127.0.0.1:8080",
54
+ models: [model1, model2],
55
+ });
56
+ serverManager = new ServerManager([server] as any);
57
+ commandManager = new CommandManager(serverManager);
58
+
59
+ const ctx = {
60
+ ui: {
61
+ notify: vi.fn(),
62
+ theme: { fg: (_: string, text: string) => text },
63
+ },
64
+ } as any;
65
+
66
+ await commandManager.handleCommand("unload", ctx, mockPi as any);
67
+
68
+ expect(model1.unload).toHaveBeenCalled();
69
+ expect(model2.unload).toHaveBeenCalled();
70
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
71
+ "Unloaded all Llama.cpp models",
72
+ "info",
73
+ );
74
+ });
101
75
 
102
- it("should unload all models when args is 'unload'", async () => {
103
- const mockModel1 = {
104
- name: "model-1",
105
- id: "model-1",
106
- unload: vi.fn().mockResolvedValue(undefined),
107
- toProviderConfig: vi.fn().mockResolvedValue({ id: "model-1" }),
108
- };
109
- const mockModel2 = {
110
- name: "model-2",
111
- id: "model-2",
112
- unload: vi.fn().mockResolvedValue(undefined),
113
- toProviderConfig: vi.fn().mockResolvedValue({ id: "model-2" }),
114
- };
115
- (isServerReady as any).mockResolvedValue(true);
116
- (listModels as any).mockResolvedValue([mockModel1, mockModel2]);
117
-
118
- const notifyFn = vi.fn();
119
- const manager = new CommandManager(mockPi as any);
120
- await manager.initialize();
121
- await manager.run("unload", {
122
- ui: { notify: notifyFn },
123
- } as any);
124
-
125
- expect(mockModel1.unload).toHaveBeenCalled();
126
- expect(mockModel2.unload).toHaveBeenCalled();
127
- expect(notifyFn).toHaveBeenCalledWith(
128
- "Unloaded all Llama.cpp models",
129
- "info",
130
- );
76
+ it("should show model info when args is 'info'", async () => {
77
+ const model1 = createMockModel("model-1");
78
+ const model2 = createMockModel("model-2");
79
+ const server = createMockServer({
80
+ baseUrl: "http://127.0.0.1:8080",
81
+ models: [model1, model2],
82
+ });
83
+ serverManager = new ServerManager([server] as any);
84
+ commandManager = new CommandManager(serverManager);
85
+
86
+ const ctx = {
87
+ ui: {
88
+ notify: vi.fn(),
89
+ theme: { fg: (_: string, text: string) => text },
90
+ },
91
+ } as any;
92
+
93
+ await commandManager.handleCommand("info", ctx, mockPi as any);
94
+
95
+ expect(model1.getInfo).toHaveBeenCalled();
96
+ expect(model2.getInfo).toHaveBeenCalled();
97
+ });
131
98
  });
132
99
 
133
- it("should dispatch modelsCommand when args is empty", async () => {
134
- const mockModel = {
135
- name: "test-model",
136
- id: "test-model",
137
- getLabel: vi.fn().mockResolvedValue("test-model"),
138
- toProviderConfig: vi.fn().mockResolvedValue({ id: "test-model" }),
100
+ describe("/models interactive menu", () => {
101
+ const CHOICE = "model-a [Server: http://127.0.0.1:8080]";
102
+
103
+ /**
104
+ * Helper to create a CommandManager with mock servers and models.
105
+ */
106
+ const createCommandManager = (
107
+ models: ReturnType<typeof createMockModel>[],
108
+ ) => {
109
+ const mockPi = createMockPi();
110
+ const servers = models.map((model) =>
111
+ createMockServer({
112
+ baseUrl: model.serverUrl,
113
+ models: [model],
114
+ }),
115
+ );
116
+ const serverManager = new ServerManager(servers as any);
117
+ return {
118
+ commandManager: new CommandManager(serverManager),
119
+ serverManager,
120
+ mockPi,
121
+ };
139
122
  };
140
- (isServerReady as any).mockResolvedValue(true);
141
- (listModels as any).mockResolvedValue([mockModel]);
142
-
143
- const selectFn = vi.fn().mockReturnValue(null); // cancel immediately
144
- const manager = new CommandManager(mockPi as any);
145
- await manager.initialize();
146
- await manager.run("", {
147
- ui: { notify: vi.fn(), select: selectFn },
148
- } as any);
149
-
150
- // modelsCommand was called (select is invoked for model picking)
151
- expect(selectFn).toHaveBeenCalled();
123
+
124
+ it("should return early on cancel (null model selection)", async () => {
125
+ const models = [createMockModel("model-a")];
126
+ const { commandManager, mockPi } = createCommandManager(models);
127
+ const ctx = createMockCtx(() => null);
128
+
129
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
130
+
131
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it("should show info when INFO action is selected", async () => {
135
+ const model = createMockModel("model-a");
136
+ const { commandManager, mockPi } = createCommandManager([model]);
137
+ let selectCallCount = 0;
138
+ const ctx = createMockCtx(() => {
139
+ selectCallCount++;
140
+ if (selectCallCount === 1) return CHOICE;
141
+ return Action.INFO;
142
+ });
143
+
144
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
145
+
146
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
147
+ "Model: model-a\nID: model-a",
148
+ "info",
149
+ );
150
+ });
151
+
152
+ it("should unload model when UNLOAD action is selected", async () => {
153
+ const model = createMockModel("model-a");
154
+ const { commandManager, mockPi } = createCommandManager([model]);
155
+ let selectCallCount = 0;
156
+ const ctx = createMockCtx(() => {
157
+ selectCallCount++;
158
+ if (selectCallCount === 1) return CHOICE;
159
+ return Action.UNLOAD;
160
+ });
161
+
162
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
163
+
164
+ expect(model.unload).toHaveBeenCalled();
165
+ expect(ctx.ui.notify).toHaveBeenCalledWith("Unloaded model-a", "info");
166
+ });
167
+
168
+ it("should switch model when SWITCH action is selected", async () => {
169
+ const model = createMockModel("model-a");
170
+ const { commandManager, mockPi } = createCommandManager([model]);
171
+ let selectCallCount = 0;
172
+ const ctx = createMockCtx(() => {
173
+ selectCallCount++;
174
+ if (selectCallCount === 1) return CHOICE;
175
+ return Action.SWITCH;
176
+ });
177
+
178
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
179
+
180
+ expect(mockPi.setModel).toHaveBeenCalled();
181
+ expect(ctx.ui.notify).toHaveBeenCalledWith("Model model-a ready", "info");
182
+ });
183
+
184
+ it("should loop back to model selection when action is cancelled", async () => {
185
+ const model = createMockModel("model-a");
186
+ const { commandManager, mockPi } = createCommandManager([model]);
187
+
188
+ let selectCallCount = 0;
189
+ const ctx = createMockCtx(() => {
190
+ selectCallCount++;
191
+ // 1st: select model-a, 2nd: cancel action, 3rd: cancel model => exit
192
+ if (selectCallCount === 1) return CHOICE;
193
+ return null;
194
+ });
195
+
196
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
197
+
198
+ expect(ctx.ui.select).toHaveBeenCalledTimes(3);
199
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
200
+ });
152
201
  });
153
202
  });
@@ -0,0 +1,256 @@
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { DEFAULT_THINKING_BUDGETS } from "../src/constants";
3
+ import { createMockModel, createMockServer } from "./mocks";
4
+
5
+ // Create a mutable mock object shared across tests
6
+ const mockSettingsManager = {
7
+ getDefaultThinkingLevel: vi.fn(() => "medium"),
8
+ getThinkingBudgets: vi.fn<() => Record<string, number> | undefined>(),
9
+ };
10
+
11
+ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
12
+ const actual =
13
+ await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
14
+ return {
15
+ ...actual,
16
+ SettingsManager: {
17
+ create: () => mockSettingsManager,
18
+ },
19
+ };
20
+ });
21
+
22
+ let EventManager: typeof import("../src/managers/events").EventManager;
23
+
24
+ beforeAll(async () => {
25
+ const mod = await vi.importActual("../src/managers/events");
26
+ EventManager =
27
+ mod.EventManager as typeof import("../src/managers/events").EventManager;
28
+ });
29
+
30
+ beforeEach(() => {
31
+ vi.restoreAllMocks();
32
+ EventManager.resetInflightModel();
33
+ mockSettingsManager.getDefaultThinkingLevel.mockReturnValue("medium");
34
+ mockSettingsManager.getThinkingBudgets.mockReturnValue(undefined);
35
+ });
36
+
37
+ const createPayload = (modelId: string) => ({
38
+ model: modelId,
39
+ messages: [{ role: "user", content: "hello" }],
40
+ });
41
+
42
+ const createNonLlamaPayload = () => ({
43
+ model: "gpt-4",
44
+ messages: [{ role: "user", content: "hello" }],
45
+ });
46
+
47
+ describe("EventManager.onBeforeProviderRequest", () => {
48
+ describe("normal usage — each thinking level", () => {
49
+ it.each([
50
+ {
51
+ level: "off",
52
+ expected: { chat_template_kwargs: { enable_thinking: false } },
53
+ },
54
+ { level: "minimal", expected: { thinking_budget_tokens: 1024 } },
55
+ { level: "low", expected: { thinking_budget_tokens: 2048 } },
56
+ { level: "medium", expected: { thinking_budget_tokens: 8192 } },
57
+ { level: "high", expected: { thinking_budget_tokens: 16384 } },
58
+ { level: "xhigh", expected: {} },
59
+ ])(
60
+ 'level "$level" should return $expected',
61
+ async ({ level, expected }) => {
62
+ mockSettingsManager.getDefaultThinkingLevel.mockReturnValue(level);
63
+
64
+ const server = createMockServer({
65
+ models: ["model-a"].map((id) => createMockModel(id)),
66
+ });
67
+ const eventManager = new EventManager([server]);
68
+ const event = { payload: createPayload("model-a") };
69
+
70
+ const result = (await eventManager.onBeforeProviderRequest(
71
+ event as any,
72
+ )) as Record<string, unknown>;
73
+
74
+ expect(result.model).toBe("model-a");
75
+ expect(result).toMatchObject(expected);
76
+ },
77
+ );
78
+
79
+ it("should preserve original payload fields alongside new ones", async () => {
80
+ mockSettingsManager.getDefaultThinkingLevel.mockReturnValue("low");
81
+
82
+ const server = createMockServer({
83
+ models: ["model-b"].map((id) => createMockModel(id)),
84
+ });
85
+ const eventManager = new EventManager([server]);
86
+ const event = {
87
+ payload: {
88
+ model: "model-b",
89
+ messages: [{ role: "user", content: "test" }],
90
+ temperature: 0.7,
91
+ },
92
+ };
93
+
94
+ const result = (await eventManager.onBeforeProviderRequest(
95
+ event as any,
96
+ )) as Record<string, unknown>;
97
+
98
+ expect(result.messages).toEqual([{ role: "user", content: "test" }]);
99
+ expect(result.temperature).toBe(0.7);
100
+ expect(result.thinking_budget_tokens).toBe(DEFAULT_THINKING_BUDGETS.low);
101
+ });
102
+ });
103
+
104
+ describe("non-llama.cpp models", () => {
105
+ it("should return the payload unchanged for unknown models", async () => {
106
+ const server = createMockServer({
107
+ models: ["model-a"].map((id) => createMockModel(id)),
108
+ });
109
+ const eventManager = new EventManager([server]);
110
+ const event = { payload: createNonLlamaPayload() };
111
+
112
+ const result = await eventManager.onBeforeProviderRequest(event as any);
113
+
114
+ expect(result).toEqual(createNonLlamaPayload());
115
+ });
116
+ });
117
+
118
+ describe("missing model in payload", () => {
119
+ it("should return the payload unchanged when model is absent", async () => {
120
+ const server = createMockServer({
121
+ models: ["model-a"].map((id) => createMockModel(id)),
122
+ });
123
+ const eventManager = new EventManager([server]);
124
+ const event = { payload: { messages: [] } };
125
+
126
+ const result = await eventManager.onBeforeProviderRequest(event as any);
127
+
128
+ expect(result).toEqual({ messages: [] });
129
+ });
130
+ });
131
+
132
+ describe("user-defined budget overrides", () => {
133
+ it("should use user-defined budgets instead of defaults", async () => {
134
+ mockSettingsManager.getDefaultThinkingLevel.mockReturnValue("low");
135
+ mockSettingsManager.getThinkingBudgets.mockReturnValue({ low: 4096 });
136
+
137
+ const server = createMockServer({
138
+ models: ["model-a"].map((id) => createMockModel(id)),
139
+ });
140
+ const eventManager = new EventManager([server]);
141
+ const event = { payload: createPayload("model-a") };
142
+
143
+ const result = (await eventManager.onBeforeProviderRequest(
144
+ event as any,
145
+ )) as Record<string, unknown>;
146
+
147
+ expect(result.thinking_budget_tokens).toBe(4096);
148
+ });
149
+
150
+ it("should merge user budgets with defaults (partial override)", async () => {
151
+ mockSettingsManager.getDefaultThinkingLevel.mockReturnValue("medium");
152
+ mockSettingsManager.getThinkingBudgets.mockReturnValue({ low: 4096 });
153
+
154
+ const server = createMockServer({
155
+ models: ["model-a"].map((id) => createMockModel(id)),
156
+ });
157
+ const eventManager = new EventManager([server]);
158
+ const event = { payload: createPayload("model-a") };
159
+
160
+ const result = (await eventManager.onBeforeProviderRequest(
161
+ event as any,
162
+ )) as Record<string, unknown>;
163
+
164
+ // medium uses default since user only overrode low
165
+ expect(result.thinking_budget_tokens).toBe(
166
+ DEFAULT_THINKING_BUDGETS.medium,
167
+ );
168
+ });
169
+ });
170
+
171
+ // ─── Edge cases ─────────────────────────────────────────────────────
172
+
173
+ describe("edge cases", () => {
174
+ it("should ignore invalid keys in user budgets (they are silently dropped)", async () => {
175
+ mockSettingsManager.getDefaultThinkingLevel.mockReturnValue("medium");
176
+ mockSettingsManager.getThinkingBudgets.mockReturnValue({
177
+ foo: 999,
178
+ bar: 123,
179
+ } as any);
180
+
181
+ const server = createMockServer({
182
+ models: ["model-a"].map((id) => createMockModel(id)),
183
+ });
184
+ const eventManager = new EventManager([server]);
185
+ const event = { payload: createPayload("model-a") };
186
+
187
+ const result = (await eventManager.onBeforeProviderRequest(
188
+ event as any,
189
+ )) as Record<string, unknown>;
190
+
191
+ // Should fall back to default since "medium" is not in user budgets
192
+ expect(result.thinking_budget_tokens).toBe(
193
+ DEFAULT_THINKING_BUDGETS.medium,
194
+ );
195
+ });
196
+
197
+ it("should not allow overriding 'off' — thinking stays disabled", async () => {
198
+ mockSettingsManager.getDefaultThinkingLevel.mockReturnValue("off");
199
+ mockSettingsManager.getThinkingBudgets.mockReturnValue({
200
+ off: 99999,
201
+ } as any);
202
+
203
+ const server = createMockServer({
204
+ models: ["model-a"].map((id) => createMockModel(id)),
205
+ });
206
+ const eventManager = new EventManager([server]);
207
+ const event = { payload: createPayload("model-a") };
208
+
209
+ const result = (await eventManager.onBeforeProviderRequest(
210
+ event as any,
211
+ )) as Record<string, unknown>;
212
+
213
+ expect(result).toMatchObject({
214
+ chat_template_kwargs: { enable_thinking: false },
215
+ });
216
+ expect(result).not.toHaveProperty("thinking_budget_tokens");
217
+ });
218
+
219
+ it("should not allow overriding 'xhigh' — no budget is injected", async () => {
220
+ mockSettingsManager.getDefaultThinkingLevel.mockReturnValue("xhigh");
221
+ mockSettingsManager.getThinkingBudgets.mockReturnValue({
222
+ xhigh: 1,
223
+ } as any);
224
+
225
+ const server = createMockServer({
226
+ models: ["model-a"].map((id) => createMockModel(id)),
227
+ });
228
+ const eventManager = new EventManager([server]);
229
+ const event = { payload: createPayload("model-a") };
230
+
231
+ const result = (await eventManager.onBeforeProviderRequest(
232
+ event as any,
233
+ )) as Record<string, unknown>;
234
+
235
+ expect(result).toEqual(createPayload("model-a"));
236
+ expect(result).not.toHaveProperty("thinking_budget_tokens");
237
+ });
238
+
239
+ it("should handle empty user budgets gracefully", async () => {
240
+ mockSettingsManager.getDefaultThinkingLevel.mockReturnValue("high");
241
+ mockSettingsManager.getThinkingBudgets.mockReturnValue({});
242
+
243
+ const server = createMockServer({
244
+ models: ["model-a"].map((id) => createMockModel(id)),
245
+ });
246
+ const eventManager = new EventManager([server]);
247
+ const event = { payload: createPayload("model-a") };
248
+
249
+ const result = (await eventManager.onBeforeProviderRequest(
250
+ event as any,
251
+ )) as Record<string, unknown>;
252
+
253
+ expect(result.thinking_budget_tokens).toBe(DEFAULT_THINKING_BUDGETS.high);
254
+ });
255
+ });
256
+ });