pi-llama-cpp 0.8.0 → 0.8.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-llama-cpp",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Pi extension for llama.cpp integration. Supports router, single and legacy models. Supports multiple servers.",
5
5
  "keywords": [
6
6
  "pi",
@@ -36,8 +36,8 @@
36
36
  "@earendil-works/pi-tui": "*"
37
37
  },
38
38
  "devDependencies": {
39
- "@types/node": "^26.0.1",
39
+ "@types/node": "^26.1.1",
40
40
  "prettier-plugin-organize-imports": "^4.3.0",
41
- "vitest": "^4.1.9"
41
+ "vitest": "^4.1.10"
42
42
  }
43
43
  }
package/src/resolver.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
- AuthStorage,
3
2
  getAgentDir,
3
+ readStoredCredential,
4
4
  SettingsManager,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { readFile } from "node:fs/promises";
@@ -16,7 +16,6 @@ export class ConfigResolver {
16
16
  private warnings: string[] = [];
17
17
 
18
18
  private cachedUrls: string[] = [];
19
- private authStorage = AuthStorage.create(join(getAgentDir(), "auth.json"));
20
19
  private settingsManager = SettingsManager.create(
21
20
  process.cwd(),
22
21
  getAgentDir(),
@@ -107,13 +106,11 @@ export class ConfigResolver {
107
106
  }
108
107
 
109
108
  /**
110
- * Resolves API key for the provider ID using Pi's AuthStorage
109
+ * Resolves API key for the provider ID using Pi's stored credentials
111
110
  */
112
- async resolveApiKey(providerId: string): Promise<string> {
113
- this.authStorage.reload();
114
- const apiKey = await this.authStorage.getApiKey(providerId);
115
-
116
- return apiKey ?? API_KEY_PLACEHOLDER;
111
+ resolveApiKey(providerId: string): string {
112
+ const credential = readStoredCredential(providerId);
113
+ return credential?.apiKey ?? API_KEY_PLACEHOLDER;
117
114
  }
118
115
 
119
116
  /**
package/src/sse/client.ts CHANGED
@@ -13,6 +13,8 @@ export class SSEClient {
13
13
  private subscribers: Map<string, SSECallback> = new Map();
14
14
  private connected: boolean = false;
15
15
  private reconnecting: boolean = false; // tracks if EventSource auto-reconnect is in progress
16
+ private _onConnectFailed: (() => void) | null = null;
17
+ private _hasReceivedEvents: boolean = false;
16
18
 
17
19
  /**
18
20
  * @param sseEndpoint - The full SSE endpoint URL (e.g., "http://127.0.0.1:8080/models/sse")
@@ -49,6 +51,11 @@ export class SSEClient {
49
51
  // EventSource will auto-reconnect; we just track state
50
52
  this.connected = false;
51
53
  this.reconnecting = true;
54
+
55
+ // Notify subscriber if connection fails before any event is received
56
+ if (!this._hasReceivedEvents && this._onConnectFailed) {
57
+ this._onConnectFailed();
58
+ }
52
59
  };
53
60
 
54
61
  this.eventSource.onmessage = (event: MessageEvent) => {
@@ -59,6 +66,7 @@ export class SSEClient {
59
66
  model: data.model ?? "*",
60
67
  data: data.data,
61
68
  };
69
+ this._hasReceivedEvents = true;
62
70
  this.dispatch(sseEvent);
63
71
  } catch {
64
72
  // Invalid JSON, ignore
@@ -79,6 +87,16 @@ export class SSEClient {
79
87
  return this.connected;
80
88
  }
81
89
 
90
+ /**
91
+ * Sets a callback to be called when the connection fails before
92
+ * any event is received. Useful for rejecting promises early.
93
+ *
94
+ * @param callback - Called once when connection fails
95
+ */
96
+ setOnConnectFailed(callback: () => void): void {
97
+ this._onConnectFailed = callback;
98
+ }
99
+
82
100
  /**
83
101
  * Subscribes to SSE events for a specific model.
84
102
  * Auto-connects if not already connected.
@@ -46,13 +46,12 @@ export class SSEManager {
46
46
  if (this.sseSupported !== null) return this.sseSupported;
47
47
 
48
48
  try {
49
- const headers: Record<string, string> = {};
49
+ let url = this.sseEndpoint;
50
50
  if (this.apiKey) {
51
- headers["Authorization"] = `Bearer ${this.apiKey}`;
51
+ url = `${url}?api_key=${encodeURIComponent(this.apiKey)}`;
52
52
  }
53
- const response = await fetch(this.sseEndpoint, {
53
+ const response = await fetch(url, {
54
54
  method: "GET",
55
- headers,
56
55
  signal: AbortSignal.timeout(SERVER_TIMEOUT),
57
56
  });
58
57
  this.sseSupported =
@@ -162,6 +161,7 @@ export class SSEManager {
162
161
  /**
163
162
  * Subscribes to SSE status change events for a specific model.
164
163
  * Resolves with the final status string once the model reaches a terminal state.
164
+ * Rejects immediately if the connection fails before any event is received.
165
165
  *
166
166
  * @param modelId - The model ID to subscribe to
167
167
  * @returns Promise that resolves with the final status string
@@ -182,6 +182,12 @@ export class SSEManager {
182
182
  }
183
183
  }
184
184
  });
185
+
186
+ // Reject immediately if the connection fails before any event is received
187
+ this.sseClient?.setOnConnectFailed(() => {
188
+ clearTimeout(timeout);
189
+ reject(new Error(`SSE connection failed for model: ${modelId}`));
190
+ });
185
191
  });
186
192
  }
187
193
 
@@ -5,22 +5,18 @@ import {
5
5
  } from "../src/constants";
6
6
 
7
7
  // Hoisted mock instances — survives vi.resetModules()
8
- const mockAuthStorage = vi.hoisted(() => ({
9
- reload: vi.fn(),
10
- getApiKey: vi.fn(),
11
- }));
8
+ const mockReadStoredCredential = vi.hoisted(() => vi.fn());
12
9
 
13
10
  const mockSettingsManager = vi.hoisted(() => ({
14
11
  getProjectSettings: vi.fn(),
15
12
  getGlobalSettings: vi.fn(),
16
13
  }));
17
14
 
18
- // Mock getAgentDir, AuthStorage, and SettingsManager before importing resolver
15
+ // Mock getAgentDir, readStoredCredential, and SettingsManager before importing resolver
19
16
  vi.mock("@earendil-works/pi-coding-agent", () => ({
20
17
  getAgentDir: vi.fn().mockReturnValue("/fake/agent/dir"),
21
- AuthStorage: {
22
- create: vi.fn().mockReturnValue(mockAuthStorage),
23
- },
18
+ readStoredCredential: (...args: unknown[]) =>
19
+ mockReadStoredCredential(...args),
24
20
  SettingsManager: {
25
21
  create: vi.fn().mockReturnValue(mockSettingsManager),
26
22
  },
@@ -145,50 +141,44 @@ describe("API key resolution", () => {
145
141
  beforeEach(() => {
146
142
  vi.clearAllMocks();
147
143
  mockGetAgentDir.mockReturnValue("/fake/agent/dir");
148
- mockAuthStorage.reload.mockReturnValue(undefined);
149
- mockAuthStorage.getApiKey.mockResolvedValue(undefined);
144
+ mockReadStoredCredential.mockReturnValue(undefined);
150
145
  });
151
146
 
152
- it("should return placeholder when auth file does not exist", async () => {
153
- mockAuthStorage.getApiKey.mockResolvedValue(undefined);
147
+ it("should return placeholder when credential is not found", () => {
148
+ mockReadStoredCredential.mockReturnValue(undefined);
154
149
 
155
150
  const resolver = new ConfigResolver();
156
- const result = await resolver.resolveApiKey(
157
- "llama-server=http://127.0.0.1:8080",
158
- );
151
+ const result = resolver.resolveApiKey("llama-server=http://127.0.0.1:8080");
159
152
 
160
153
  expect(result).toEqual(API_KEY_PLACEHOLDER);
161
154
  });
162
155
 
163
- it("should return placeholder when provider key is missing", async () => {
164
- mockAuthStorage.getApiKey.mockResolvedValue(undefined);
156
+ it("should return placeholder when apiKey is missing from credential", () => {
157
+ mockReadStoredCredential.mockReturnValue({});
165
158
 
166
159
  const resolver = new ConfigResolver();
167
- const result = await resolver.resolveApiKey(
168
- "llama-server=http://127.0.0.1:8080",
169
- );
160
+ const result = resolver.resolveApiKey("llama-server=http://127.0.0.1:8080");
170
161
 
171
162
  expect(result).toEqual(API_KEY_PLACEHOLDER);
172
163
  });
173
164
 
174
- it("should return the provider key when present", async () => {
175
- mockAuthStorage.getApiKey.mockResolvedValue("test-api-key");
165
+ it("should return the apiKey when present in credential", () => {
166
+ mockReadStoredCredential.mockReturnValue({ apiKey: "test-api-key" });
176
167
 
177
168
  const resolver = new ConfigResolver();
178
- const result = await resolver.resolveApiKey(
179
- "llama-server=http://127.0.0.1:8080",
180
- );
169
+ const result = resolver.resolveApiKey("llama-server=http://127.0.0.1:8080");
181
170
 
182
171
  expect(result).toEqual("test-api-key");
183
172
  });
184
173
 
185
- it("should call reload before each getApiKey", async () => {
186
- mockAuthStorage.getApiKey.mockResolvedValue("cached-key");
174
+ it("should call readStoredCredential with the provider ID", () => {
175
+ mockReadStoredCredential.mockReturnValue({ apiKey: "test-key" });
187
176
 
188
177
  const resolver = new ConfigResolver();
189
- await resolver.resolveApiKey("llama-server=http://127.0.0.1:8080");
190
- await resolver.resolveApiKey("llama-server=http://127.0.0.1:8080");
178
+ resolver.resolveApiKey("llama-server=http://127.0.0.1:8080");
191
179
 
192
- expect(mockAuthStorage.reload).toHaveBeenCalledTimes(2);
180
+ expect(mockReadStoredCredential).toHaveBeenCalledWith(
181
+ "llama-server=http://127.0.0.1:8080",
182
+ );
193
183
  });
194
184
  });
package/PLAN.md DELETED
@@ -1,349 +0,0 @@
1
- # Model Download Feature — Implementation Plan
2
-
3
- ## Overview
4
-
5
- Add a `/models-download` command that lets users:
6
-
7
- - **Download** a model by typing a HuggingFace repo string (e.g. `ggml-org/gemma-3-4b-it-GGUF:Q4_K_M`)
8
- - **Cancel** an in-progress download
9
- - **Delete** a cached model via `/models-download delete`
10
-
11
- Download progress is shown in real time via the existing SSE infrastructure. After completion, the user is notified and invited to run `/models` to refresh.
12
-
13
- Extra documentation of the server's internals can be found in `~/Proyectos/llama-server/README.md`.
14
-
15
- ---
16
-
17
- ## Key Insight
18
-
19
- When downloading, the model **does not yet exist** in the local model list. The HF repo string itself acts as the model identifier in SSE events. This means:
20
-
21
- - Download/cancel/delete are **`Server` operations**, not `BaseModel` methods.
22
- - SSE subscriptions use the raw HF repo string as the `modelId`.
23
- - No changes to `BaseModel` are needed.
24
-
25
- ## Command Structure
26
-
27
- The `/models-download` command has three subcommands:
28
-
29
- | Subcommand | Description |
30
- | ---------------------------- | ------------------------------ |
31
- | `/models-download` (no args) | Interactive download flow |
32
- | `/models-download delete` | Interactive delete flow |
33
- | `/models-download cancel` | Cancel an in-progress download |
34
-
35
- Each subcommand is routed by `DownloadManager.handleCommand(args, ctx, pi)`.
36
-
37
- ---
38
-
39
- ## Files to Create
40
-
41
- ### `src/managers/download.ts` — DownloadManager
42
-
43
- Orchestrates the download, cancel, and delete workflows.
44
-
45
- ```
46
- class DownloadManager
47
- ├── constructor(serverManager, pi)
48
- ├── handleCommand(args, ctx, pi) → routes to download / delete / cancel flow
49
- ├── handleDownloadFlow(ctx) → interactive: input HF repo → confirm → download with progress
50
- ├── handleDeleteFlow(ctx) → interactive: select deletable model → confirm → delete
51
- ├── handleCancelFlow(ctx) → cancel in-progress download
52
- └── helpers: selectServer, selectModel
53
- ```
54
-
55
- **Dependencies:** `ServerManager` (needs `getServers()` accessor), `pi: ExtensionAPI` (for cleanup notifications).
56
-
57
- ---
58
-
59
- ## Files to Modify
60
-
61
- ### 1. `src/server.ts`
62
-
63
- Add four methods:
64
-
65
- | Method | HTTP | Purpose |
66
- | ------------------------ | ------------------------------------ | ----------------------------------------- |
67
- | `downloadModel(hfRepo)` | `POST /models` | Trigger download with `{ model: hfRepo }` |
68
- | `deleteModel(modelId)` | `DELETE /models?model={id}` | Remove cached model |
69
- | `cancelDownload(hfRepo)` | `POST /models/unload` | Cancel in-progress download |
70
- | `probeSSESupport()` | delegates to `SSEManager.probeSSE()` | Check `/models/sse` availability |
71
-
72
- **Note:** All four endpoints are confirmed by the llama-server [API documentation](https://github.com/ggml-org/llama.cpp/blob/main/server/README.md). `POST /models/unload` works for both unloading loaded models AND canceling downloads.
73
-
74
- **Also:** Add `ApiClient.do_delete()` for DELETE requests. Unlike `get`/`post`, `delete` does **not** need caching since it's a mutation. No public `delete()` wrapper is needed (unlike `get`/`post`).
75
-
76
- ```typescript
77
- // New signature
78
- async do_delete<T>(endpoint: string): Promise<T>
79
- ```
80
-
81
- `do_delete` makes a raw DELETE request with `Authorization: Bearer ${this.apiKey}` (same auth pattern as `do_get`/`do_post`).
82
-
83
- **Note:** No changes to `BaseModel`. Download and delete are server-level operations that work with model IDs/strings directly, not model instances.
84
-
85
- ### 2. `src/managers/server.ts`
86
-
87
- Add a public accessor (currently `servers` is private):
88
-
89
- ```typescript
90
- getServers(): Server[] {
91
- return this.servers;
92
- }
93
- ```
94
-
95
- ### 3. `src/interfaces/endpoints/models.ts`
96
-
97
- Add `can_remove` and `need_download` to `DataProperty` (they're already there — confirmed). No changes needed.
98
-
99
- ### 4. `src/index.ts`
100
-
101
- - Import and instantiate `DownloadManager`
102
- - Register the `/models-download` command
103
- - Pass `serverManager` and `pi` to it
104
-
105
- ### 5. `src/sse/manager.ts`
106
-
107
- Add `subscribeToDownloadStatus(modelId: string): Promise<'success' | 'failed'>` — see implementation details in the SSE Event Mapping section above.
108
-
109
- ### 6. `src/sse/types.ts`
110
-
111
- Add `download_finished` and `download_failed` to `SSEEventType` (they're already there — confirmed). No changes needed.
112
-
113
- ---
114
-
115
- ## Download Flow (detailed)
116
-
117
- ```
118
- User: /models-download
119
- │
120
- ├─ SSE supported? (probeSSESupport)
121
- │ └─ No → notify error, exit
122
- │
123
- ├─ Always select server (even if only one) → ctx.ui.select("Select server:", [...])
124
- │ └─ Format: "Llama.cpp (http://127.0.0.1:8080)"
125
- │
126
- ├─ ctx.ui.input("Enter HuggingFace model", "user/repo:quant")
127
- │ └─ Cancelled → exit
128
- │
129
- ├─ Basic validation (must contain "/")
130
- │ └─ Invalid → notify error, exit
131
- │
132
- ├─ ctx.ui.confirm("Confirm Download", "Download <repo>?")
133
- │ └─ No → exit
134
- │
135
- ├─ server.downloadModel(hfRepo)
136
- │ └─ Response: { success: true } or { error: { code, message, type } }
137
- │ └─ If error → notify error, exit
138
- │
139
- ├─ Subscribe to SSE progress (hfRepo as modelId)
140
- │ └─ On each event: ctx.ui.notify("Downloading ... X%", "info")
141
- │
142
- ├─ Wait for terminal SSE event (download_finished / download_failed)
143
- │ ├─ Success → notify "Download complete. Run /models to refresh."
144
- │ └─ Failure → notify "Download failed: <reason>"
145
- │
146
- ├─ Call GET /models to update the model list
147
- │
148
- └─ Cleanup SSE subscription
149
- ```
150
-
151
- ### Download Timeout
152
-
153
- Downloads can take several minutes. **No timeout** is applied to the download status subscription — the SSE connection will wait indefinitely for `download_finished` or `download_failed`. The `subscribeToProgress` call already handles progress updates in real time.
154
-
155
- ## Delete Flow (detailed)
156
-
157
- ```
158
- User: /models-download delete
159
- │
160
- ├─ Always select server (even if only one) → ctx.ui.select("Select server:", [...])
161
- │ └─ Format: "Llama.cpp (http://127.0.0.1:8080)"
162
- │
163
- ├─ Fetch fresh model list from server (GET /models)
164
- │
165
- ├─ Filter models where can_remove === true
166
- │ └─ None → notify "No deletable models", exit
167
- │ └─ Note: models still downloading are NOT in the list yet, so they cannot be deleted.
168
- │
169
- ├─ ctx.ui.select("Select model to delete:", [...])
170
- │ └─ Cancelled → exit
171
- │
172
- ├─ ctx.ui.confirm("Confirm Delete", "Delete <name>? This cannot be undone.")
173
- │ └─ No → exit
174
- │
175
- ├─ server.deleteModel(modelId)
176
- │ └─ Response: { success: true }
177
- │ ├─ Success → notify "Deleted <name>. Run /models to refresh."
178
- │ └─ Failure → notify error
179
- │
180
- └─ Done
181
- ```
182
-
183
- ## Cancel Download
184
-
185
- The `cancelDownload` method on `Server` sends `POST /models/unload` with the HF repo string. Per the llama-server docs, `POST /models/unload` cancels in-progress downloads (it's the same endpoint used to unload loaded models).
186
-
187
- Exposed as `/models-download cancel` subcommand. The flow:
188
-
189
- ```
190
- User: /models-download cancel
191
- │
192
- ├─ Always select server (even if only one) → ctx.ui.select("Select server:", [...])
193
- │ └─ Format: "Llama.cpp (http://127.0.0.1:8080)"
194
- │
195
- ├─ ctx.ui.input("Enter HF repo to cancel", "user/repo:quant")
196
- │ └─ Cancelled → exit
197
- │
198
- ├─ server.cancelDownload(hfRepo)
199
- │ └─ Sends POST /models/unload with { model: hfRepo }
200
- │
201
- ├─ Notify: "Download cancelled for <repo>."
202
- │
203
- └─ Done
204
- ```
205
-
206
- **Note:** `cancelDownload` is semantically distinct from `downloadModel` even though they use the same endpoint. It should be a separate method for clarity.
207
-
208
- ---
209
-
210
- ## SSE Event Mapping
211
-
212
- The existing `SSEManager` already handles these event types:
213
-
214
- | SSE Event | Current handling | Used by |
215
- | ------------------------------- | ------------------------------------------------------------ | ------------- |
216
- | `download_progress` | `subscribeToProgress()` parses `done/total` → percentage | Download flow |
217
- | `status_change` with `progress` | `subscribeToProgress()` parses `value` → percentage + stage | Download flow |
218
- | `download_finished` | Not explicitly handled yet — need to add terminal resolution | Download flow |
219
- | `download_failed` | Not explicitly handled yet — need to add terminal rejection | Download flow |
220
-
221
- **Gap:** `SSEManager.subscribeToStatus()` currently only resolves on `status_change` events with `loaded`/`failed`. For downloads, we need it to also resolve on `download_finished`/`download_failed` events. Two options:
222
-
223
- - **(A)** Add a new `subscribeToDownloadStatus(modelId)` method that resolves on download terminal events.
224
- - **(B)** Extend `subscribeToStatus()` to also handle download terminal events.
225
-
226
- **Recommendation:** Option (A) — keep concerns separated. The download manager calls `subscribeToDownloadStatus()`.
227
-
228
- **`subscribeToDownloadStatus(modelId)` implementation:**
229
-
230
- ```typescript
231
- subscribeToDownloadStatus(modelId: string): Promise<'success' | 'failed'> {
232
- return new Promise((resolve, reject) => {
233
- // No timeout — downloads can take several minutes.
234
- // The SSE connection waits indefinitely for a terminal event.
235
-
236
- this.subscribeToSSE(modelId, (event: SSEEvent) => {
237
- if (event.event === SSEEventType.download_finished) {
238
- resolve('success');
239
- } else if (event.event === SSEEventType.download_failed) {
240
- reject(new Error(`Download failed for model: ${modelId}`));
241
- }
242
- });
243
- });
244
- }
245
- ```
246
-
247
- - Subscribes to SSE for the HF repo string as `modelId`.
248
- - Resolves with `"success"` on `download_finished`.
249
- - Rejects with an error on `download_failed`.
250
- - The event `data` field contains the model identifier string (e.g., `"ggml-org/gemma-3-4b-it-GGUF:Q4_K_M"`), but we don't need to parse it — the `modelId` parameter already identifies the model.
251
-
252
- **SSE Event Payloads** (from llama-server docs):
253
-
254
- ```json
255
- // download_finished / download_failed
256
- {
257
- "model": "ggml-org/gemma-3-4b-it-GGUF:Q4_K_M"
258
- }
259
- ```
260
-
261
- Both events carry the model identifier in `data`. No additional error details — errors are surfaced via the event type itself (`download_failed` vs `download_finished`).
262
-
263
- ```json
264
- // model_remove — sent after successful DELETE /models
265
- {
266
- "model": "ggml-org/gemma-3-4b-it-GGUF:Q4_K_M"
267
- }
268
- ```
269
-
270
- The `model_remove` event is sent by the server after a successful delete. The extension doesn't need to handle it explicitly — the user runs `/models` to refresh the list anyway. But it's good to be aware of it in case we want to auto-refresh later.
271
-
272
- ```json
273
- // models_reload — sent after model list changes (download completes, delete completes)
274
- {
275
- "model": "*"
276
- }
277
- ```
278
-
279
- The `models_reload` event uses `"model": "*"` (global event). This is sent after downloads complete and after deletes. We don't need to handle it in v1 — the download flow explicitly calls `GET /models` to refresh. But it's useful context for future auto-refresh features.
280
-
281
- **Note:** `SSEManager.probeSSE()` already exists and is cached. `SSEEventType` already includes `download_finished` and `download_failed`. The `subscribeToProgress()` method already handles `download_progress` events. We just need to add a terminal resolver.
282
-
283
- ---
284
-
285
- ## Notification Types
286
-
287
- Pi's `ctx.ui.notify()` accepts `"info" | "warning" | "error"`. There is no `"success"` type. Use `"info"` for success messages.
288
-
289
- ---
290
-
291
- ## API Response Formats
292
-
293
- ### `POST /models` (download)
294
-
295
- **Success:**
296
-
297
- ```json
298
- { "success": true }
299
- ```
300
-
301
- **Error:**
302
-
303
- ```json
304
- {
305
- "error": {
306
- "code": 400,
307
- "message": "model validation failed, unable to download",
308
- "type": "invalid_request_error"
309
- }
310
- }
311
- ```
312
-
313
- ### `DELETE /models?model={id}` (delete)
314
-
315
- **Success:**
316
-
317
- ```json
318
- { "success": true }
319
- ```
320
-
321
- ### `POST /models/unload` (cancel download)
322
-
323
- Same endpoint as unload — response format not explicitly documented by llama-server. Likely returns `{ success: true }` or an empty object. We'll treat any non-throwing response as success.
324
-
325
- ---
326
-
327
- ## Server Selection UX
328
-
329
- In all three flows (download, delete, cancel), the user always selects a server first — even if there's only one server configured. This keeps the UX consistent and future-proofs the feature.
330
-
331
- ```typescript
332
- // Example: server list labels
333
- const choices = servers.map((s) => `${PROVIDER_NAME} (${s.baseUrl})`);
334
- // → ["Llama.cpp (http://127.0.0.1:8080)", "Llama.cpp (http://127.0.0.1:8081)"]
335
- const choice = await ctx.ui.select("Select server:", choices);
336
- ```
337
-
338
- ---
339
-
340
- ## TODO Comments
341
-
342
- Add `// TODO: Verify if download/delete needs to be gated to router mode` in:
343
-
344
- - `DownloadManager.handleCommand()`
345
- - `Server.downloadModel()`
346
- - `Server.deleteModel()`
347
- - `Server.cancelDownload()`
348
-
349
- ---