pi-llama-cpp 0.4.0 → 0.5.1

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 CHANGED
@@ -111,6 +111,7 @@ The extension determines the context size as follows:
111
111
  | ---------------- | ------------------------------------------------------------------------------------------ |
112
112
  | `/models` | Browse your models with live status. Select a model to load, switch, or unload it. |
113
113
  | `/models info` | Show detailed information for all available models at once. |
114
+ | `/models unload` | Unload all loaded models at once (Note: this only makes sense in router mode). |
114
115
 
115
116
  > **Note:** When the llama.cpp server is unreachable, `/models` displays an error notification with the configured server URL.
116
117
 
@@ -142,9 +143,9 @@ When you trigger a load, switch, or retry action, the extension polls the server
142
143
 
143
144
  Each model exposed to Pi includes the following defaults:
144
145
 
145
- - **`maxTokens`** — `32000` (maximum possible tokens per response according to Pi's source code)
146
+ - **`maxTokens`** — dynamically set to the model's context window (detected from llama-server)
146
147
  - **`reasoning`** — `true` (assumed, as llama.cpp's `/models` endpoint does not expose it)
147
- - **`cost`** — all zero (local model)
148
+ - **`cost`** — all zero (local models)
148
149
 
149
150
  ## Dependencies
150
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-llama-cpp",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Pi extension for llama.cpp integration. Supports both router and single modes.",
5
5
  "keywords": [
6
6
  "pi",
@@ -24,8 +24,7 @@
24
24
  ]
25
25
  },
26
26
  "scripts": {
27
- "test": "vitest",
28
- "test:run": "vitest run"
27
+ "test": "vitest run"
29
28
  },
30
29
  "prettier": {
31
30
  "plugins": [
@@ -36,8 +35,8 @@
36
35
  "@earendil-works/pi-coding-agent": "*"
37
36
  },
38
37
  "devDependencies": {
39
- "@types/node": "^25.6.0",
38
+ "@types/node": "^25.9.1",
40
39
  "prettier-plugin-organize-imports": "^4.3.0",
41
- "vitest": "^4.1.5"
40
+ "vitest": "^4.1.7"
42
41
  }
43
42
  }
@@ -1,14 +1,43 @@
1
1
  import type {
2
2
  ExtensionAPI,
3
3
  ExtensionCommandContext,
4
+ ExtensionContext,
5
+ SessionBeforeSwitchEvent,
4
6
  } from "@earendil-works/pi-coding-agent";
5
- import { PROVIDER_ID, PROVIDER_NAME } from "../constants";
7
+ import { PROVIDER_ID, PROVIDER_NAME, READABLE_TIMEOUT } from "../constants";
6
8
  import { Action } from "../enums/action";
7
9
  import { Mode } from "../enums/mode";
8
10
  import { Status } from "../enums/status";
9
11
  import { BaseModel } from "../models/baseModel";
10
12
  import { resolveUrl } from "../tools/resolver";
11
13
 
14
+ // In-flight model reference — handler gates on this.
15
+ let inflightModel: BaseModel | null = null;
16
+
17
+ export const resetInflightModel = () => (inflightModel = null);
18
+
19
+ /**
20
+ * Session-switch handler. Registered once at extension init.
21
+ * Only notifies if a model load is actually in-flight.
22
+ */
23
+ export const onSessionBeforeSwitch = async (
24
+ _event: SessionBeforeSwitchEvent,
25
+ ctx: ExtensionContext,
26
+ ) => {
27
+ if (!inflightModel) return;
28
+
29
+ const messages = [
30
+ `Session change detected while model '${inflightModel.name}' was still loading.`,
31
+ "Model load will continue in the background, but UI might not update.",
32
+ "",
33
+ "Verify that your new model is loaded, or use /models to re-select it afterwards.",
34
+ ];
35
+ ctx.ui.notify(messages.join("\n"), "warning");
36
+
37
+ // Show the notification for a reasonable amount of time
38
+ await new Promise((r) => setTimeout(r, READABLE_TIMEOUT));
39
+ };
40
+
12
41
  /**
13
42
  * Select a model from the list. Returns null if user cancels.
14
43
  *
@@ -167,6 +196,7 @@ export const modelsCommand = async (
167
196
  const loadActions = [Action.LOAD, Action.SWITCH, Action.RETRY];
168
197
  if (loadActions.includes(action)) {
169
198
  ctx.ui.notify(`Loading ${model.name}...`, "info");
199
+ inflightModel = model;
170
200
 
171
201
  const onSuccess = async () => {
172
202
  const piModel = ctx.modelRegistry.find(PROVIDER_ID, model.id);
@@ -175,7 +205,7 @@ export const modelsCommand = async (
175
205
  }
176
206
 
177
207
  if ((await model.getStatus()) === Status.FAILED) {
178
- throw new Error(`Failed to load model ${model.name}`);
208
+ throw new Error(`Failed to load model ${model.name}`);
179
209
  }
180
210
 
181
211
  await pi.setModel(piModel);
@@ -184,10 +214,15 @@ export const modelsCommand = async (
184
214
 
185
215
  const onFailure = (err: any) => {
186
216
  const message = err instanceof Error ? err.message : String(err);
187
- ctx.ui.notify(message, "error");
217
+
218
+ try {
219
+ ctx.ui.notify(message, "error");
220
+ } catch {
221
+ // ctx went stale between error and notification
222
+ }
188
223
  };
189
224
 
190
225
  // Load the model without blocking the UI
191
- model.load().then(onSuccess).catch(onFailure);
226
+ model.load().then(onSuccess).catch(onFailure).finally(resetInflightModel);
192
227
  }
193
228
  };
package/src/constants.ts CHANGED
@@ -23,11 +23,6 @@ export const API_KEY_PLACEHOLDER = "sk-placeholder";
23
23
  */
24
24
  export const DEFAULT_CTX = 128000;
25
25
 
26
- /**
27
- * Maximum number of tokens a model can generate in a single response
28
- */
29
- export const MAX_TOKENS = 32000;
30
-
31
26
  /**
32
27
  * Polling interval (ms) for checking model load status
33
28
  */
@@ -37,3 +32,8 @@ export const POLLING_INTERVAL = 500;
37
32
  * Maximum time (ms) to wait for model loading before giving up
38
33
  */
39
34
  export const POLLING_TIMEOUT = 60000;
35
+
36
+ /**
37
+ * Reasonable time to read notifications if context goes stale
38
+ */
39
+ export const READABLE_TIMEOUT = 15000;
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionCommandContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
6
+ import { onSessionBeforeSwitch } from "./commands/models";
5
7
  import { PROVIDER_NAME } from "./constants";
6
8
  import { onModelSelect } from "./events";
7
9
  import { CommandManager } from "./manager";
@@ -13,10 +15,28 @@ export default async function (pi: ExtensionAPI) {
13
15
  // Command: /models
14
16
  pi.registerCommand("models", {
15
17
  description: `Browse ${PROVIDER_NAME} models`,
18
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
19
+ const available = [
20
+ {
21
+ value: "info",
22
+ label: "info",
23
+ description: "Show information of all models",
24
+ },
25
+ {
26
+ value: "unload",
27
+ label: "unload",
28
+ description: "Unload all models",
29
+ },
30
+ ];
31
+
32
+ const filtered = available.filter((a) => a.value.startsWith(prefix));
33
+ return filtered.length > 0 ? filtered : null;
34
+ },
16
35
  handler: async (args: string, ctx: ExtensionCommandContext) =>
17
36
  await manager.run(args, ctx),
18
37
  });
19
38
 
20
39
  // Events registration
21
40
  pi.on("model_select", onModelSelect);
41
+ pi.on("session_before_switch", onSessionBeforeSwitch);
22
42
  }
package/src/manager.ts CHANGED
@@ -66,13 +66,15 @@ export class CommandManager {
66
66
  * @param args Arguments passed to the command
67
67
  * @param ctx The context used by Pi
68
68
  * @param pi The Pi extension
69
- * @returns A command handler
70
69
  */
71
70
  async run(args: string, ctx: ExtensionCommandContext) {
72
71
  if (!(await isServerReady())) {
73
72
  return await notFoundCommand(ctx);
74
73
  }
75
74
 
75
+ // Refresh the model list from the server
76
+ await this.update();
77
+
76
78
  // Command: `/models info`
77
79
  if (args === "info") {
78
80
  const info = await Promise.all(this.serverModels.map((m) => m.getInfo()));
@@ -81,7 +83,14 @@ export class CommandManager {
81
83
  return;
82
84
  }
83
85
 
84
- // Command: `/models`
86
+ // Command: `/models unload`
87
+ if (args === "unload") {
88
+ await Promise.all(this.serverModels.map((m) => m.unload()));
89
+ ctx.ui.notify(`Unloaded all ${PROVIDER_NAME} models`, "info");
90
+ return;
91
+ }
92
+
93
+ // Command: `/models` (interactive menu)
85
94
  return await modelsCommand(ctx, this.pi, this.serverModels);
86
95
  }
87
96
  }
@@ -1,5 +1,5 @@
1
1
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
- import { MAX_TOKENS, POLLING_INTERVAL, POLLING_TIMEOUT } from "../constants";
2
+ import { POLLING_INTERVAL, POLLING_TIMEOUT } from "../constants";
3
3
  import { Mode } from "../enums/mode";
4
4
  import { Status } from "../enums/status";
5
5
  import { DataProperty, ModelsEndpoint } from "../interfaces/endpoints/models";
@@ -127,7 +127,7 @@ export abstract class BaseModel {
127
127
  input: await this.getCapabilities(),
128
128
  contextWindow: await this.getContextSize(),
129
129
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
130
- maxTokens: MAX_TOKENS,
130
+ maxTokens: await this.getContextSize(),
131
131
  };
132
132
 
133
133
  return response;
@@ -1,3 +1,4 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
1
2
  import { access, constants, readFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import {
@@ -59,7 +60,7 @@ const readConfigValue = async <T>(
59
60
  * @returns The API key, as defined by the auth.json file
60
61
  */
61
62
  export const resolveApiKey = async (): Promise<string> => {
62
- const authPath = join(process.env.HOME || ".", ".pi", "agent", "auth.json");
63
+ const authPath = join(getAgentDir(), "settings.json");
63
64
  if (!(await fileExists(authPath))) return API_KEY_PLACEHOLDER;
64
65
 
65
66
  const cfg = await readConfigValue<AuthFile>(authPath, PROVIDER_ID);
@@ -71,13 +72,7 @@ export const resolveApiKey = async (): Promise<string> => {
71
72
  * @returns The URL, if found.
72
73
  */
73
74
  const resolveGlobalUrl = async (): Promise<string | null> => {
74
- const globalPath = join(
75
- process.env.HOME || ".",
76
- ".pi",
77
- "agent",
78
- "settings.json",
79
- );
80
-
75
+ const globalPath = join(getAgentDir(), "settings.json");
81
76
  if (!(await fileExists(globalPath))) return null;
82
77
 
83
78
  return readConfigValue<Record<string, string>>(globalPath, "llamaServerUrl");
@@ -1,6 +1,6 @@
1
- import { describe, expect, it, vi, beforeEach } from "vitest";
2
- import { CommandManager } from "../src/manager";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
2
  import { PROVIDER_ID, PROVIDER_NAME } from "../src/constants";
3
+ import { CommandManager } from "../src/manager";
4
4
 
5
5
  // Mock modules at top level (vi.mock is hoisted)
6
6
  vi.mock("../src/tools/retriever", () => ({
@@ -14,8 +14,8 @@ vi.mock("../src/tools/resolver", () => ({
14
14
  }));
15
15
 
16
16
  // Import mocked functions after vi.mock
17
+ import { resolveApiKey, resolveUrl } from "../src/tools/resolver";
17
18
  import { isServerReady, listModels } from "../src/tools/retriever";
18
- import { resolveUrl, resolveApiKey } from "../src/tools/resolver";
19
19
 
20
20
  const mockPi = {
21
21
  registerProvider: vi.fn(),
@@ -47,7 +47,9 @@ describe("CommandManager", () => {
47
47
  const mockModel = {
48
48
  name: "test-model",
49
49
  id: "test-model",
50
- toProviderConfig: vi.fn().mockResolvedValue({ id: "test-model", maxTokens: 32000 }),
50
+ toProviderConfig: vi
51
+ .fn()
52
+ .mockResolvedValue({ id: "test-model", maxTokens: 32000 }),
51
53
  };
52
54
  (isServerReady as any).mockResolvedValue(true);
53
55
  (listModels as any).mockResolvedValue([mockModel]);
@@ -92,11 +94,40 @@ describe("CommandManager", () => {
92
94
  ui: { notify: notifyFn, theme: { fg: (_c: string, t: string) => t } },
93
95
  } as any);
94
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
+ });
101
+
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();
95
127
  expect(notifyFn).toHaveBeenCalledWith(
96
- "Model info for test-model",
128
+ "Unloaded all Llama.cpp models",
97
129
  "info",
98
130
  );
99
- expect(listModels).toHaveBeenCalledOnce();
100
131
  });
101
132
 
102
133
  it("should dispatch modelsCommand when args is empty", async () => {
@@ -1,5 +1,14 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { modelsCommand } from "../src/commands/models";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Set up fake timers before any imports so setTimeout is mocked globally
4
+ vi.useFakeTimers();
5
+
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ modelsCommand,
9
+ onSessionBeforeSwitch,
10
+ resetInflightModel,
11
+ } from "../src/commands/models";
3
12
  import { Action } from "../src/enums/action";
4
13
  import { Mode } from "../src/enums/mode";
5
14
  import { Status } from "../src/enums/status";
@@ -48,11 +57,27 @@ const createMockCtx = (
48
57
  },
49
58
  });
50
59
 
60
+ const createMockPiContext = (notifyFn: ReturnType<typeof vi.fn>) =>
61
+ ({
62
+ ui: {
63
+ notify: notifyFn,
64
+ },
65
+ }) as any as ExtensionContext;
66
+
51
67
  const createMockPi = () => ({
52
68
  setModel: vi.fn(),
53
69
  registerProvider: vi.fn(),
54
70
  });
55
71
 
72
+ beforeEach(() => {
73
+ vi.clearAllTimers();
74
+ resetInflightModel();
75
+ });
76
+
77
+ afterEach(() => {
78
+ vi.clearAllTimers();
79
+ });
80
+
56
81
  describe("modelsCommand", () => {
57
82
  it("should return early on cancel (null model selection)", async () => {
58
83
  const models = [createMockModel("model-a")];
@@ -113,6 +138,117 @@ describe("modelsCommand", () => {
113
138
  await vi.waitFor(() => expect(pi.setModel).toHaveBeenCalled());
114
139
  });
115
140
 
141
+ it("should show warning when session changes during model load", async () => {
142
+ // Create a deferred promise so we can control when the load completes
143
+ let resolveLoad: () => void;
144
+ const loadPromise = new Promise<void>((resolve) => {
145
+ resolveLoad = resolve;
146
+ });
147
+ const model = createMockModel("model-a", {
148
+ load: () => loadPromise,
149
+ getStatus: vi.fn().mockResolvedValue(Status.UNLOADED),
150
+ });
151
+ const models = [model];
152
+
153
+ let selectCallCount = 0;
154
+ const ctx = createMockCtx(() => {
155
+ selectCallCount++;
156
+ // 1st: select model, 2nd: select LOAD
157
+ if (selectCallCount === 1) return "model-a";
158
+ if (selectCallCount === 2) return Action.LOAD;
159
+ return null;
160
+ });
161
+ const pi = createMockPi();
162
+
163
+ // Start the load (non-blocking)
164
+ const modelsPromise = modelsCommand(ctx as any, pi as any, models);
165
+
166
+ // Advance past the microtask that sets inflightModel
167
+ await vi.advanceTimersByTimeAsync(0);
168
+
169
+ // Simulate session switch while model is still loading
170
+ // onSessionBeforeSwitch awaits READABLE_TIMEOUT (15s) for the notification
171
+ const switchPromise = onSessionBeforeSwitch(
172
+ {} as any,
173
+ createMockPiContext(ctx.ui.notify as any),
174
+ );
175
+ await vi.advanceTimersByTimeAsync(15000);
176
+ await switchPromise;
177
+
178
+ // Should have shown a warning notification
179
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
180
+ expect.stringContaining("Session change detected"),
181
+ "warning",
182
+ );
183
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
184
+ expect.stringContaining("model-a"),
185
+ "warning",
186
+ );
187
+
188
+ // Complete the load so inflightModel is cleared
189
+ resolveLoad!();
190
+ await modelsPromise;
191
+ });
192
+
193
+ it("should not warn when no model is loading", async () => {
194
+ const notifyFn = vi.fn();
195
+ const ctx = createMockPiContext(notifyFn);
196
+
197
+ await onSessionBeforeSwitch({} as any, ctx);
198
+
199
+ expect(notifyFn).not.toHaveBeenCalled();
200
+ // No timers should be scheduled
201
+ expect(vi.getTimerCount()).toBe(0);
202
+ });
203
+
204
+ it("should clear inflightModel after load completes successfully", async () => {
205
+ const loadFn = vi.fn().mockResolvedValue(undefined);
206
+ const model = createMockModel("model-a", {
207
+ load: loadFn,
208
+ getStatus: vi.fn().mockResolvedValue(Status.UNLOADED),
209
+ });
210
+ const models = [model];
211
+ const ctx = createMockCtx((prompt) => {
212
+ if (prompt.includes("models")) return "model-a";
213
+ return Action.LOAD;
214
+ });
215
+ const pi = createMockPi();
216
+
217
+ await modelsCommand(ctx as any, pi as any, models);
218
+ await vi.waitFor(() => expect(loadFn).toHaveBeenCalled());
219
+ await vi.waitFor(() => expect(pi.setModel).toHaveBeenCalled());
220
+
221
+ // inflightModel should be cleared after completion
222
+ // (verified indirectly: calling onSessionBeforeSwitch should not warn)
223
+ await vi.advanceTimersByTimeAsync(0);
224
+ const notifyFn = vi.fn();
225
+ await onSessionBeforeSwitch({} as any, createMockPiContext(notifyFn));
226
+ expect(notifyFn).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it("should clear inflightModel after load fails", async () => {
230
+ const loadFn = vi.fn().mockRejectedValue(new Error("Load failed"));
231
+ const model = createMockModel("model-a", {
232
+ load: loadFn,
233
+ getStatus: vi.fn().mockResolvedValue(Status.FAILED),
234
+ });
235
+ const models = [model];
236
+ const ctx = createMockCtx((prompt) => {
237
+ if (prompt.includes("models")) return "model-a";
238
+ return Action.RETRY;
239
+ });
240
+ const pi = createMockPi();
241
+
242
+ await modelsCommand(ctx as any, pi as any, models);
243
+ await vi.waitFor(() => expect(loadFn).toHaveBeenCalled());
244
+
245
+ // inflightModel should be cleared after failure
246
+ await vi.advanceTimersByTimeAsync(0);
247
+ const notifyFn = vi.fn();
248
+ await onSessionBeforeSwitch({} as any, createMockPiContext(notifyFn));
249
+ expect(notifyFn).not.toHaveBeenCalled();
250
+ });
251
+
116
252
  it("should loop back to model selection when action is cancelled", async () => {
117
253
  const model = createMockModel("model-a");
118
254
  const models = [model];
@@ -65,7 +65,9 @@ describe("SingleModel getStatus", () => {
65
65
  const status = await model.getStatus();
66
66
 
67
67
  expect(status).toBe(Status.LOADED);
68
- expect(mockRpc).toHaveBeenCalledWith(`/props?model=${model.id}&autoload=false`);
68
+ expect(mockRpc).toHaveBeenCalledWith(
69
+ `/props?model=${model.id}&autoload=false`,
70
+ );
69
71
  });
70
72
 
71
73
  it("should return SLEEPING when is_sleeping is true", async () => {