pi-llama-cpp 0.13.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 (65) hide show
  1. package/README.md +7 -8
  2. package/package.json +2 -2
  3. package/src/api/client.ts +25 -0
  4. package/src/constants.ts +0 -5
  5. package/src/enums/status.ts +0 -1
  6. package/src/interfaces/endpoints/models.ts +1 -1
  7. package/src/interfaces/settings.ts +1 -1
  8. package/src/interfaces/sortBy.ts +4 -0
  9. package/src/managers/command/models.ts +247 -0
  10. package/src/managers/command.ts +29 -461
  11. package/src/managers/events.ts +2 -2
  12. package/src/managers/server.ts +27 -3
  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 +11 -29
  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/serverIds.ts +21 -0
  49. package/src/utils/settingsStore.ts +1 -1
  50. package/src/utils/urlResolver.ts +129 -0
  51. package/src/utils/urls.ts +33 -13
  52. package/tests/commandManager.test.ts +14 -9
  53. package/tests/dialog.test.ts +55 -4
  54. package/tests/legacyModel.test.ts +34 -28
  55. package/tests/overrides.test.ts +111 -88
  56. package/tests/routerModel.test.ts +67 -68
  57. package/tests/server.test.ts +0 -12
  58. package/tests/settings.test.ts +10 -8
  59. package/tests/singleModel.test.ts +12 -12
  60. package/tests/sseManager.test.ts +6 -24
  61. package/src/ui/dialog.ts +0 -290
  62. package/src/ui/overrideEntryEditor.ts +0 -119
  63. package/src/ui/overrideSettingsList.ts +0 -710
  64. package/src/ui/serverListEditor.ts +0 -59
  65. package/src/ui/serverSettingsList.ts +0 -513
package/README.md CHANGED
@@ -17,14 +17,13 @@ A [Pi Coding Agent](https://pi.dev/) extension that integrates with running [lla
17
17
 
18
18
  ### Status Indicators
19
19
 
20
- | Icon | Status | Description |
21
- | ---- | ------------ | -------------------------------------- |
22
- | 🟢 | Loaded | Model is active and ready to use |
23
- | 🟡 | Loading | Model is currently being loaded |
24
- | 🔴 | Failed | Model failed to load |
25
- | 🔵 | Sleeping | Model is available, but inactive |
26
- | ⚪ | Unloaded | Model is not loaded on the server |
27
- | ⛔ | Unauthorized | Model can't be used (API key required) |
20
+ | Icon | Status | Description |
21
+ | ---- | -------- | --------------------------------- |
22
+ | 🟢 | Loaded | Model is active and ready to use |
23
+ | 🟡 | Loading | Model is currently being loaded |
24
+ | 🔴 | Failed | Model failed to load |
25
+ | 🔵 | Sleeping | Model is available, but inactive |
26
+ | ⚪ | Unloaded | Model is not loaded on the server |
28
27
 
29
28
  > **Note**: The `Sleeping` status only shows when you start your server with `llama-server --sleep-idle-seconds <n> ...`.
30
29
  > This is a **llama.cpp server flag** that tells the server to put idle models to sleep after `n` seconds.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-llama-cpp",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Pi extension for llama.cpp integration. Supports router, single and legacy models. Supports multiple servers.",
5
5
  "keywords": [
6
6
  "pi",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "type": "module",
40
40
  "devDependencies": {
41
- "@types/node": "^26.4.1",
41
+ "@types/node": "^26.6.2",
42
42
  "prettier-plugin-organize-imports": "^4.3.0",
43
43
  "vitest": "^5.0.1"
44
44
  }
package/src/api/client.ts CHANGED
@@ -1,5 +1,18 @@
1
1
  import { POLLING_INTERVAL } from "../constants";
2
2
 
3
+ /**
4
+ * Error thrown by the API client when the server rejects a request.
5
+ */
6
+ export class ApiError extends Error {
7
+ constructor(
8
+ public readonly type: "authentication" | "server" | "unknown",
9
+ public readonly statusCode: number,
10
+ ) {
11
+ super(`API error (${statusCode})`);
12
+ this.name = "ApiError";
13
+ }
14
+ }
15
+
3
16
  /**
4
17
  * How long GET responses stay cached: half the polling interval, so a poll
5
18
  * tick always reaches the server while multiple reads within one tick
@@ -126,6 +139,8 @@ export class ApiClient {
126
139
  *
127
140
  * @param endpoint The endpoint path to fetch (e.g. "/health")
128
141
  * @returns The parsed JSON response from the server
142
+ * @throws ApiError with status 401 when the server rejects the request
143
+ * due to authentication (invalid/missing API key).
129
144
  */
130
145
  private async do_get<T>(endpoint: string): Promise<T> {
131
146
  const url = `${this.baseUrl}${endpoint}`;
@@ -134,6 +149,10 @@ export class ApiClient {
134
149
  headers: { Authorization: `Bearer ${this.apiKey}` },
135
150
  });
136
151
 
152
+ if (res.status === 401) {
153
+ throw new ApiError("authentication", res.status);
154
+ }
155
+
137
156
  return res.json();
138
157
  }
139
158
 
@@ -144,6 +163,8 @@ export class ApiClient {
144
163
  * @param endpoint The endpoint path to post to
145
164
  * @param body The optional request body
146
165
  * @returns The parsed JSON response from the server
166
+ * @throws ApiError with status 401 when the server rejects the request
167
+ * due to authentication (invalid/missing API key).
147
168
  */
148
169
  private async do_post<T>(
149
170
  endpoint: string,
@@ -160,6 +181,10 @@ export class ApiClient {
160
181
  body: body ? JSON.stringify(body) : undefined,
161
182
  });
162
183
 
184
+ if (res.status === 401) {
185
+ throw new ApiError("authentication", res.status);
186
+ }
187
+
163
188
  return res.json();
164
189
  }
165
190
  }
package/src/constants.ts CHANGED
@@ -76,11 +76,6 @@ export const AUTOLOAD_ON_MESSAGE = false;
76
76
  */
77
77
  export const SORT_BY = "asc";
78
78
 
79
- /**
80
- * Sort order options for model lists.
81
- */
82
- export type SortBy = "asc" | "desc" | "asc-name" | "desc-name" | "api";
83
-
84
79
  /**
85
80
  * Thinking budgets to send to the server, depending on user-selected level in Pi.
86
81
  */
@@ -5,5 +5,4 @@ export enum Status {
5
5
  FAILED = "failed",
6
6
  SLEEPING = "sleeping",
7
7
  UNLOADED = "unloaded",
8
- UNAUTHORIZED = "unauthorized",
9
8
  }
@@ -10,7 +10,7 @@ export interface ModelsEndpoint {
10
10
  data: DataProperty[];
11
11
  }
12
12
 
13
- export interface ModelProperty {
13
+ interface ModelProperty {
14
14
  name: string;
15
15
  model: string;
16
16
  modified_at: string;
@@ -1,5 +1,5 @@
1
1
  import type { ModelCost, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
2
- import type { SortBy } from "../constants";
2
+ import type { SortBy } from "./sortBy";
3
3
 
4
4
  /**
5
5
  * Per-model overrides applied on top of what llama-server reports.
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Sort order options for model lists.
3
+ */
4
+ export type SortBy = "asc" | "desc" | "asc-name" | "desc-name" | "api";
@@ -0,0 +1,247 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { PROVIDER_NAME } from "../../constants";
6
+ import { Action } from "../../enums/action";
7
+ import { Mode } from "../../enums/mode";
8
+ import { Status } from "../../enums/status";
9
+ import { BaseModel } from "../../models/baseModel";
10
+ import { errorMessage } from "../../utils/errors";
11
+ import { EventManager } from "../events";
12
+ import type { ServerManager } from "../server";
13
+
14
+ /**
15
+ * Interactive model selection/action flow for the `/models` command.
16
+ *
17
+ * Owns the select-model → select-action loop and the per-status action
18
+ * tables, plus the non-blocking model load pipeline (progress events,
19
+ * inflight tracking, success/failure handling).
20
+ *
21
+ * Split out of `CommandManager`: command routing stays there;
22
+ * this class is the self-contained interactive concern.
23
+ * `ServerManager` is injected so load/refresh paths can reach the
24
+ * server registry without coupling the menu to command routing.
25
+ */
26
+ export class ModelsMenu {
27
+ constructor(private readonly serverManager: ServerManager) {}
28
+
29
+ /**
30
+ * Runs the interactive model selection menu.
31
+ */
32
+ async show(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
33
+ const event = await this.selectionLoop(
34
+ ctx,
35
+ await this.serverManager.getAllModels(),
36
+ );
37
+
38
+ if (!event) return;
39
+ const { action, model } = event;
40
+
41
+ // Action: Cancel
42
+ if (!action || action === Action.CANCEL) return;
43
+
44
+ // Action: Info
45
+ if (action === Action.INFO) {
46
+ const info = await model.getInfo();
47
+ ctx.ui.notify(`${info}`, "info");
48
+ return;
49
+ }
50
+
51
+ // Action: Unload
52
+ if (action === Action.UNLOAD) {
53
+ await model.unload();
54
+ ctx.ui.notify(`Unloaded ${model.name}`, "info");
55
+ return;
56
+ }
57
+
58
+ // Action: Switch
59
+ if (action === Action.SWITCH) {
60
+ const { serverId } = model;
61
+ const piModel = ctx.modelRegistry.find(serverId, model.id);
62
+ if (!piModel)
63
+ throw new Error(`Cannot find model ${model.name} in pi registry`);
64
+
65
+ await pi.setModel(piModel);
66
+ ctx.ui.notify(`Model ${model.name} ready`, "info");
67
+ return;
68
+ }
69
+
70
+ // Actions: Load / Load & Switch / Retry
71
+ const loadActions = [Action.LOAD, Action.LOAD_AND_SWITCH, Action.RETRY];
72
+ if (loadActions.includes(action)) {
73
+ ctx.ui.notify(`Loading ${model.name}...`, "info");
74
+ // Mark the load as in-flight so session_before_switch can warn about
75
+ // it (see EventManager.inflightModel for the coupling rationale)
76
+ EventManager.inflightModel = model;
77
+
78
+ // Subscribe to progress events; skip when the server is gone
79
+ // (removed/edited away mid-load → getServer returns undefined)
80
+ const server = this.serverManager.getServer(model);
81
+ const cleanupProgress =
82
+ server?.sseManager.subscribeToProgress(
83
+ model.id,
84
+ (percentage, stage) => {
85
+ const stageText = stage ? ` (${stage})` : "";
86
+ ctx.ui.notify(
87
+ `Loading ${model.name}... [${percentage}%${stageText}]`,
88
+ "info",
89
+ );
90
+ },
91
+ ) ?? (() => {});
92
+
93
+ const onSuccess = async () => {
94
+ const { serverId } = model;
95
+ const piModel = ctx.modelRegistry.find(serverId, model.id);
96
+ if (!piModel)
97
+ throw new Error(`Cannot find model ${model.name} in pi registry`);
98
+
99
+ // Verify failure
100
+ if ((await model.getStatus()) === Status.FAILED)
101
+ throw new Error(`Failed to load model ${model.name}`);
102
+
103
+ // Select the model if asked
104
+ if (action === Action.LOAD_AND_SWITCH) await pi.setModel(piModel);
105
+
106
+ ctx.ui.notify(`Model ${model.name} ready`, "info");
107
+ };
108
+
109
+ const onFailure = (err: any) => {
110
+ const message = errorMessage(err);
111
+
112
+ try {
113
+ ctx.ui.notify(message, "error");
114
+ } catch {
115
+ // ctx went stale between error and notification
116
+ }
117
+ };
118
+
119
+ const onFinished = async () => {
120
+ cleanupProgress();
121
+ EventManager.resetInflightModel();
122
+
123
+ // Re-scan providers to ensure accuracy of loaded models
124
+ await this.serverManager.update(pi);
125
+
126
+ // Force TUI refresh so Pi picks up the updated model states
127
+ ctx.ui.setStatus(PROVIDER_NAME, " ");
128
+ ctx.ui.setStatus(PROVIDER_NAME, undefined);
129
+ };
130
+
131
+ // Load the model without blocking the UI
132
+ model.load().then(onSuccess).catch(onFailure).finally(onFinished);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Handles the menu for model selection.
138
+ * Loops: select model → select action → handle action.
139
+ *
140
+ * Escape on actions menu goes back to model selection.
141
+ * Escape on model selection exits.
142
+ *
143
+ * @returns The selected action and model
144
+ */
145
+ private async selectionLoop(
146
+ ctx: ExtensionCommandContext,
147
+ models: BaseModel[],
148
+ ): Promise<{ action: Action; model: BaseModel } | null> {
149
+ while (true) {
150
+ // Select the model
151
+ const model = await this.selectModel(ctx, models);
152
+ if (!model) return null;
153
+
154
+ // Select the action
155
+ const actions = await this.getActionsForModel(model);
156
+ const action = await this.selectAction(ctx, model, actions);
157
+ if (action === null) {
158
+ // Escape key pressed => back to model selection
159
+ continue;
160
+ }
161
+
162
+ // Return the selected action and model
163
+ return { action, model };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Select a model from the list. Returns null if user cancels.
169
+ *
170
+ * @returns The model selected by the user
171
+ */
172
+ private async selectModel(
173
+ ctx: ExtensionCommandContext,
174
+ models: BaseModel[],
175
+ ): Promise<BaseModel | null> {
176
+ const labels = await Promise.all(
177
+ models.map(async (model) => ({
178
+ label: (await model.getLabel()).trim(),
179
+ serverUrl: model.serverUrl,
180
+ })),
181
+ );
182
+
183
+ // Count grapheme clusters (not UTF-16 code units) so emoji padding aligns visually
184
+ const graphemeLength = (str: string) =>
185
+ [...new Intl.Segmenter().segment(str)].length;
186
+
187
+ // Decorate the label so the spacing makes it seem more like a table
188
+ const maxLength = Math.max(
189
+ ...labels.map(({ label }) => graphemeLength(label)),
190
+ );
191
+ const choices = labels.map(({ label, serverUrl }) => {
192
+ const extraPadding = 2;
193
+ const padLen = maxLength - graphemeLength(label) + extraPadding;
194
+ return `${label}${" ".repeat(padLen)} [Server: ${serverUrl}]`;
195
+ });
196
+
197
+ const choice = await ctx.ui.select(`${PROVIDER_NAME} models:`, choices);
198
+ if (!choice) return null;
199
+ const idx = choices.indexOf(choice);
200
+
201
+ return models[idx];
202
+ }
203
+
204
+ /**
205
+ * Get available actions for a model based on its mode and status.
206
+ *
207
+ * @returns A mapping of actions for each status
208
+ */
209
+ private async getActionsForModel(model: BaseModel): Promise<Array<Action>> {
210
+ const base = [Action.INFO, Action.CANCEL];
211
+
212
+ const actions: Record<Status, Array<Action>> = {
213
+ [Status.LOADED]:
214
+ model.mode === Mode.ROUTER
215
+ ? [Action.SWITCH, Action.UNLOAD, ...base]
216
+ : [Action.SWITCH, ...base],
217
+ [Status.LOADING]: [...base],
218
+ [Status.FAILED]: [Action.RETRY, ...base],
219
+ [Status.SLEEPING]:
220
+ model.mode === Mode.ROUTER
221
+ ? [Action.SWITCH, Action.UNLOAD, ...base]
222
+ : [Action.SWITCH, ...base],
223
+ [Status.UNLOADED]: [Action.LOAD_AND_SWITCH, Action.LOAD, ...base],
224
+ };
225
+
226
+ const status = await model.getStatus();
227
+ return actions[status];
228
+ }
229
+
230
+ /**
231
+ * Selects an action for a model.
232
+ *
233
+ * @returns The selected action
234
+ */
235
+ private async selectAction(
236
+ ctx: ExtensionCommandContext,
237
+ model: BaseModel,
238
+ actions: Array<Action>,
239
+ ): Promise<Action | null> {
240
+ const labels = actions.map((a) => String(a));
241
+ const choice = await ctx.ui.select(`${model.name}`, labels);
242
+ if (!choice) return null;
243
+
244
+ const idx = labels.indexOf(choice);
245
+ return actions[idx];
246
+ }
247
+ }