pi-llama-cpp 0.7.1 → 0.8.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/PLAN.md ADDED
@@ -0,0 +1,349 @@
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
+ ---
package/README.md CHANGED
@@ -13,6 +13,7 @@ A [Pi Coding Agent](https://pi.dev/) extension that integrates with running [lla
13
13
  - **Auth support** — allows to login into a llama.cpp server that was secured with an API key
14
14
  - **Multiple server support** — connect to multiple llama.cpp servers simultaneously by separating URLs with semicolons
15
15
  - **Thinking budget support** — configurable token budgets for model reasoning/thinking, mapped to Pi's thinking levels
16
+ - **Real-time progress tracking** — live loading progress via SSE (falls back to polling)
16
17
 
17
18
  ### Status Indicators
18
19
 
@@ -127,10 +128,10 @@ llama-server --model path/to/model.gguf ...
127
128
  The extension determines the context size as follows:
128
129
 
129
130
  - **Router mode**
130
- - When loaded, reads `meta.n_ctx` from the `/models` endpoint
131
+ - When loaded, reads `meta.n_ctx` from the `/v1/models` endpoint
131
132
  - When not loaded, reads `--ctx-size` and/or `--fit-ctx` from the server arguments (which can also originate from the **presets.ini** file the llama.cpp server uses to load its models).
132
- - **Single mode** — reads `meta.n_ctx` from the `/models` endpoint
133
- - **Legacy mode** — reads `max_model_len` from `/models`, falling back to `n_ctx` from `/props`
133
+ - **Single mode** — reads `meta.n_ctx` from the `/v1/models` endpoint
134
+ - **Legacy mode** — reads `max_model_len` from `/v1/models`, falling back to `n_ctx` from `/props`
134
135
  - Falls back to `128000` if not available
135
136
 
136
137
  ### Commands
@@ -200,16 +201,18 @@ This keeps the server in sync with the active model in Pi, regardless of how the
200
201
 
201
202
  ### Loading Models
202
203
 
203
- When you trigger a load, switch, or retry action, the extension polls the server to track progress. If a model takes longer than **60 seconds** to load, the polling times out with an error.
204
+ When you trigger a load, switch, or retry action, the extension uses SSE (Server-Sent Events) to receive real-time progress updates from the server. If SSE is not available, it falls back to polling.
204
205
 
205
- > **Note:** The timeout is only for the polling. The model might still be loading.
206
+ If loading takes longer than **60 seconds**, the operation times out with an error.
207
+
208
+ > **Note:** The timeout only applies to the progress detection. The model might still be loading in the background.
206
209
 
207
210
  ### Model Configuration
208
211
 
209
212
  Each model exposed to Pi includes the following defaults:
210
213
 
211
214
  - **`maxTokens`** — dynamically set to the model's context window (detected from llama-server)
212
- - **`reasoning`** — `true` (assumed, as llama.cpp's `/models` endpoint does not expose it)
215
+ - **`reasoning`** — `true` (assumed, as llama.cpp's `/v1/models` endpoint does not expose it)
213
216
  - **`cost`** — all zero (local models)
214
217
 
215
218
  ## Dependencies
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-llama-cpp",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",
@@ -36,8 +36,8 @@
36
36
  "@earendil-works/pi-tui": "*"
37
37
  },
38
38
  "devDependencies": {
39
- "@types/node": "^25.9.3",
39
+ "@types/node": "^26.0.1",
40
40
  "prettier-plugin-organize-imports": "^4.3.0",
41
- "vitest": "^4.1.8"
41
+ "vitest": "^4.1.9"
42
42
  }
43
43
  }
@@ -0,0 +1,121 @@
1
+ import { POLLING_INTERVAL } from "../constants";
2
+ import { Cache } from "../utils/cache";
3
+ import { Mutex } from "../utils/mutex";
4
+
5
+ /**
6
+ * HTTP client for llama-server with caching and deduplication.
7
+ */
8
+ export class ApiClient {
9
+ private cache = new Cache(POLLING_INTERVAL / 2);
10
+ private mutex = new Mutex();
11
+
12
+ /**
13
+ * Creates a new ApiClient.
14
+ *
15
+ * @param baseUrl The base URL of the llama-server
16
+ * @param apiKey The API key for authentication
17
+ */
18
+ constructor(
19
+ private readonly baseUrl: string,
20
+ private readonly apiKey: string,
21
+ ) {}
22
+
23
+ /**
24
+ * Makes a cached, deduplicated GET request to the llama-server.
25
+ * Results are cached for half the polling interval and in-flight requests are deduplicated.
26
+ *
27
+ * @param endpoint The endpoint path to fetch (e.g. "/health")
28
+ * @returns The parsed JSON response from the server
29
+ */
30
+ async get<T>(endpoint: string): Promise<T> {
31
+ const cached = this.cache.get<T>(endpoint);
32
+ if (cached !== undefined) return cached;
33
+
34
+ return this.mutex.getOrCreate(endpoint, async () => {
35
+ const data = (await this.do_get<T>(endpoint)) as T;
36
+ this.cache.set(endpoint, data);
37
+ return data;
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Makes a cached, deduplicated POST request to the llama-server.
43
+ * Results are cached for half the polling interval and in-flight requests are deduplicated.
44
+ *
45
+ * @param endpoint The endpoint path to post to
46
+ * @param body The optional request body
47
+ * @returns The parsed JSON response from the server
48
+ */
49
+ async post<T>(endpoint: string, body?: Record<string, unknown>): Promise<T> {
50
+ const key = this.cacheKey(endpoint, body);
51
+ const cached = this.cache.get<T>(key);
52
+ if (cached !== undefined) return cached;
53
+
54
+ return this.mutex.getOrCreate(key, async () => {
55
+ const data = (await this.do_post<T>(endpoint, body)) as T;
56
+ this.cache.set(key, data);
57
+ return data;
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Clears the entire cache.
63
+ */
64
+ clearCache(): void {
65
+ this.cache.clear();
66
+ }
67
+
68
+ /**
69
+ * Makes a raw GET request to the llama-server.
70
+ * This bypasses caching and deduplication.
71
+ *
72
+ * @param endpoint The endpoint path to fetch (e.g. "/health")
73
+ * @returns The parsed JSON response from the server
74
+ */
75
+ private async do_get<T>(endpoint: string): Promise<T> {
76
+ const url = `${this.baseUrl}${endpoint}`;
77
+
78
+ const res = await fetch(url, {
79
+ headers: { Authorization: `Bearer ${this.apiKey}` },
80
+ });
81
+
82
+ return res.json();
83
+ }
84
+
85
+ /**
86
+ * Makes a raw POST request to the llama-server.
87
+ * This bypasses caching and deduplication.
88
+ *
89
+ * @param endpoint The endpoint path to post to
90
+ * @param body The optional request body
91
+ * @returns The parsed JSON response from the server
92
+ */
93
+ private async do_post<T>(
94
+ endpoint: string,
95
+ body?: Record<string, unknown>,
96
+ ): Promise<T> {
97
+ const url = `${this.baseUrl}${endpoint}`;
98
+
99
+ const res = await fetch(url, {
100
+ method: "POST",
101
+ headers: {
102
+ "Content-Type": "application/json",
103
+ Authorization: `Bearer ${this.apiKey}`,
104
+ },
105
+ body: body ? JSON.stringify(body) : undefined,
106
+ });
107
+
108
+ return res.json();
109
+ }
110
+
111
+ /**
112
+ * Sets a cache key
113
+ *
114
+ * @param endpoint The endpoint path to post to
115
+ * @param body The optional request body
116
+ * @returns The cache key
117
+ */
118
+ private cacheKey(endpoint: string, body?: Record<string, unknown>): string {
119
+ return body ? `${endpoint}:${JSON.stringify(body)}` : endpoint;
120
+ }
121
+ }
package/src/constants.ts CHANGED
@@ -44,7 +44,7 @@ export const POLLING_TIMEOUT = 60000;
44
44
  export const READABLE_TIMEOUT = 15000;
45
45
 
46
46
  /**
47
- * Timeout (ms) for server verification before assuming failure
47
+ * Timeout (ms) for server verification and SSE support probe
48
48
  */
49
49
  export const SERVER_TIMEOUT = 1000;
50
50
 
@@ -40,6 +40,9 @@ export interface DataProperty {
40
40
  created: number;
41
41
  status?: StatusProperty;
42
42
  architecture?: ArchitectureProperty;
43
+ source?: string;
44
+ can_remove?: boolean;
45
+ need_download?: boolean;
43
46
  meta?: MetaProperty;
44
47
  }
45
48
 
@@ -2,28 +2,56 @@
2
2
  * The structure of llama-server's /props endpoint
3
3
  */
4
4
  export interface PropsEndpoint {
5
- role?: "router";
6
- error?: PropsError;
5
+ role: "router";
6
+ max_instances: number;
7
+ models_autoload: boolean;
8
+ model_alias: string;
9
+ model_path: string;
7
10
  default_generation_settings: Record<string, any>;
11
+ ui_settings: Record<string, any>;
12
+ build_info: string;
13
+ cors_proxy_enabled: boolean;
14
+ }
15
+
16
+ /**
17
+ * The structure of llama-server's /props?model=<id> endpoint
18
+ */
19
+ export interface PropsModelEndpoint {
20
+ error?: PropsError;
21
+ default_generation_settings: {
22
+ params: Record<string, any>;
23
+ n_ctx: number;
24
+ };
8
25
  total_slots: number;
9
26
  model_alias: string;
10
27
  model_path: string;
11
28
  modalities: {
12
29
  vision: boolean;
30
+ video: boolean;
13
31
  audio: boolean;
14
32
  };
15
33
  media_marker: string;
16
34
  endpoint_slots: boolean;
17
35
  endpoint_props: boolean;
18
36
  endpoint_metrics: boolean;
19
- webui: boolean;
20
- webui_settings: Record<string, any>;
37
+ ui: boolean;
38
+ ui_settings: Record<string, any>;
21
39
  chat_template: string;
22
- chat_template_caps: Record<string, boolean>;
40
+ chat_template_caps: {
41
+ supports_object_arguments: boolean;
42
+ supports_parallel_tool_calls: boolean;
43
+ supports_preserve_reasoning: boolean;
44
+ supports_string_content: boolean;
45
+ supports_system_role: boolean;
46
+ supports_tool_calls: boolean;
47
+ supports_tools: boolean;
48
+ supports_typed_content: boolean;
49
+ };
23
50
  bos_token: string;
24
51
  eos_token: string;
25
52
  build_info: string;
26
53
  is_sleeping: boolean;
54
+ cors_proxy_enabled: boolean;
27
55
  }
28
56
 
29
57
  export interface PropsError {
@@ -134,6 +134,17 @@ export class CommandManager {
134
134
  ctx.ui.notify(`Loading ${model.name}...`, "info");
135
135
  EventManager.inflightModel = model;
136
136
 
137
+ // Subscribe to progress events
138
+ const cleanupProgress = this.serverManager
139
+ .getServer(model)
140
+ .sseManager.subscribeToProgress(model.id, (percentage, stage) => {
141
+ const stageText = stage ? ` (${stage})` : "";
142
+ ctx.ui.notify(
143
+ `Loading ${model.name}... [${percentage}%${stageText}]`,
144
+ "info",
145
+ );
146
+ });
147
+
137
148
  const onSuccess = async () => {
138
149
  const { serverId } = model;
139
150
  const piModel = ctx.modelRegistry.find(serverId, model.id);
@@ -171,7 +182,10 @@ export class CommandManager {
171
182
  .load()
172
183
  .then(onSuccess)
173
184
  .catch(onFailure)
174
- .finally(EventManager.resetInflightModel);
185
+ .finally(() => {
186
+ cleanupProgress();
187
+ EventManager.resetInflightModel();
188
+ });
175
189
  }
176
190
  }
177
191
 
@@ -117,6 +117,16 @@ export class ServerManager {
117
117
  return warnings;
118
118
  }
119
119
 
120
+ /**
121
+ * Returns the server for a given model.
122
+ *
123
+ * @param model - The model to find the server for
124
+ * @returns The server containing the model
125
+ */
126
+ getServer(model: BaseModel): Server {
127
+ return this.servers.find((s) => s.baseUrl === model.serverUrl)!;
128
+ }
129
+
120
130
  /**
121
131
  * Returns all models from all servers.
122
132
  *
@@ -87,9 +87,11 @@ export abstract class BaseModel {
87
87
  const model = data.find((d) => d.id === this.id);
88
88
  if (!model) return ["text"];
89
89
 
90
- const { input_modalities } = model.architecture!;
90
+ const input_modalities: ("text" | "image" | "audio")[] = model
91
+ .architecture?.input_modalities ?? ["text"];
92
+
91
93
  const response = input_modalities.filter(
92
- (mod) => mod === "text" || mod === "image",
94
+ (mod): mod is "text" | "image" => mod === "text" || mod === "image",
93
95
  );
94
96
 
95
97
  return response;
@@ -189,14 +191,25 @@ export abstract class BaseModel {
189
191
  }
190
192
 
191
193
  /**
192
- * Loads the model in llama-server
194
+ * Loads the model in llama-server.
195
+ * Uses SSE status events when available, falling back to polling.
193
196
  */
194
197
  async load(): Promise<void> {
195
198
  const status = await this.getStatus();
196
199
  if (status === Status.LOADED || status === Status.SLEEPING) return;
197
200
 
198
201
  await this.server.postRequest("load", this.id);
199
- await this.pollStatus();
202
+
203
+ if (await this.server.sseManager.probeSSE()) {
204
+ const { status, exit_code } =
205
+ await this.server.sseManager.subscribeToStatus(this.id);
206
+
207
+ if (status === "failed" || (status === "unloaded" && exit_code !== 0)) {
208
+ throw new Error(`Model loading failed: ${this.id}`);
209
+ }
210
+ } else {
211
+ await this.pollStatus();
212
+ }
200
213
  }
201
214
 
202
215
  /**
@@ -14,8 +14,7 @@ export class RouterModel extends BaseModel {
14
14
  }
15
15
 
16
16
  /**
17
- * Workaround for the currently-bugged /models status detection
18
- * (I suspect it was introduced in PR #22683 of llama.cpp)
17
+ * Workaround for /models status detection
19
18
  *
20
19
  * When a model is loaded for the very first time,
21
20
  * this workaround will try to poll to /props instead of /models
package/src/server.ts CHANGED
@@ -1,21 +1,35 @@
1
+ import { ApiClient } from "./api/client";
1
2
  import { PROVIDER_NAME, PROVIDER_PREFIX } from "./constants";
2
3
  import { Mode } from "./enums/mode";
3
4
  import { ServerStatus } from "./enums/serverStatus";
4
5
  import { HealthEndpoint } from "./interfaces/endpoints/health";
5
6
  import { ModelsEndpoint } from "./interfaces/endpoints/models";
6
- import { PropsEndpoint } from "./interfaces/endpoints/props";
7
+ import {
8
+ PropsEndpoint,
9
+ PropsModelEndpoint,
10
+ } from "./interfaces/endpoints/props";
7
11
  import { BaseModel } from "./models/baseModel";
8
12
  import { LegacyModel } from "./models/legacyModel";
9
13
  import { RouterModel } from "./models/routerModel";
10
14
  import { SingleModel } from "./models/singleModel";
11
15
  import { ConfigResolver } from "./resolver";
16
+ import { SSEManager } from "./sse/manager";
12
17
 
13
18
  export class Server {
14
19
  public readonly models: BaseModel[] = [];
15
20
  private configResolver = new ConfigResolver();
21
+ private apiClient!: ApiClient;
22
+ private sse!: SSEManager;
16
23
 
17
24
  constructor(readonly baseUrl: string) {}
18
25
 
26
+ /**
27
+ * Provides access to the SSE manager for direct subscriptions.
28
+ */
29
+ get sseManager(): SSEManager {
30
+ return this.sse;
31
+ }
32
+
19
33
  /**
20
34
  * Generates a unique provider ID from a server URL.
21
35
  */
@@ -39,9 +53,13 @@ export class Server {
39
53
  }
40
54
 
41
55
  /**
42
- * Fetches models from the server and populates {@link models}
56
+ * Fetches models from the server and populates {@link models}.
57
+ * Clears the cache first so we always fetch fresh data.
43
58
  */
44
59
  async initialize() {
60
+ const apiKey = await this.getApiKey();
61
+ this.apiClient = new ApiClient(this.baseUrl, apiKey);
62
+ this.sse = new SSEManager(this.baseUrl, apiKey);
45
63
  const { data } = await this.fetchModels();
46
64
  const mode = await this.detectServerMode();
47
65
 
@@ -81,6 +99,8 @@ export class Server {
81
99
  * @returns The server status
82
100
  */
83
101
  async isReady(timeout: number): Promise<ServerStatus> {
102
+ this.apiClient ??= new ApiClient(this.baseUrl, await this.getApiKey());
103
+
84
104
  try {
85
105
  const timeoutPromise = new Promise<never>((_, reject) =>
86
106
  setTimeout(() => reject(new Error("timeout")), timeout),
@@ -107,7 +127,7 @@ export class Server {
107
127
  * @returns The health status
108
128
  */
109
129
  async fetchServerHealth(): Promise<HealthEndpoint> {
110
- return await this.rpc<HealthEndpoint>("/health");
130
+ return await this.apiClient.get<HealthEndpoint>("/health");
111
131
  }
112
132
 
113
133
  /**
@@ -116,7 +136,7 @@ export class Server {
116
136
  * @return The models from the server
117
137
  */
118
138
  async fetchModels(): Promise<ModelsEndpoint> {
119
- return await this.rpc<ModelsEndpoint>("/v1/models");
139
+ return await this.apiClient.get<ModelsEndpoint>("/v1/models");
120
140
  }
121
141
 
122
142
  /**
@@ -125,7 +145,7 @@ export class Server {
125
145
  * @return The properties of the server
126
146
  */
127
147
  async fetchServerProps(): Promise<PropsEndpoint> {
128
- return await this.rpc<PropsEndpoint>("/props?autoload=false");
148
+ return await this.apiClient.get<PropsEndpoint>("/props?autoload=false");
129
149
  }
130
150
 
131
151
  /**
@@ -134,8 +154,8 @@ export class Server {
134
154
  * @param modelId The ID of the model
135
155
  * @return The properties of the specified model
136
156
  */
137
- async fetchModelProps(modelId: string): Promise<PropsEndpoint> {
138
- return await this.rpc<PropsEndpoint>(
157
+ async fetchModelProps(modelId: string): Promise<PropsModelEndpoint> {
158
+ return await this.apiClient.get<PropsModelEndpoint>(
139
159
  `/props?model=${modelId}&autoload=false`,
140
160
  );
141
161
  }
@@ -150,38 +170,9 @@ export class Server {
150
170
  resource: "load" | "unload",
151
171
  model: string,
152
172
  ): Promise<ModelsEndpoint> {
153
- return await this.rpc<ModelsEndpoint>(`/models/${resource}`, { model });
154
- }
155
-
156
- /**
157
- * Makes an HTTP request to the llama-server and returns the parsed JSON response
158
- *
159
- * @param endpoint The endpoint path to fetch (e.g. "/health")
160
- * @param body The optional request body for POST requests
161
- * @returns The parsed JSON response from the server
162
- */
163
- private async rpc<T>(
164
- endpoint: string,
165
- body?: Record<string, unknown>,
166
- ): Promise<T> {
167
- const url = `${this.baseUrl}${endpoint}`;
168
- const apiKey = await this.getApiKey();
169
-
170
- const data = {
171
- method: body ? "POST" : "GET",
172
- headers: body ? { "Content-Type": "application/json" } : undefined,
173
- body: body ? JSON.stringify(body) : undefined,
174
- };
175
-
176
- const res = await fetch(url, {
177
- ...data,
178
- headers: {
179
- ...data.headers,
180
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
181
- },
173
+ this.apiClient.clearCache();
174
+ return await this.apiClient.post<ModelsEndpoint>(`/models/${resource}`, {
175
+ model,
182
176
  });
183
-
184
- const response: T = await res.json();
185
- return response;
186
177
  }
187
178
  }
@@ -0,0 +1,140 @@
1
+ import { POLLING_INTERVAL } from "../constants";
2
+ import type { SSECallback, SSECleanup, SSEEvent } from "./types";
3
+
4
+ /**
5
+ * SSE client for llama-server's /models/sse endpoint.
6
+ *
7
+ * Uses a single shared EventSource per server instance.
8
+ * Supports multiple model subscriptions with automatic event routing.
9
+ * Handles reconnection by re-subscribing all callbacks.
10
+ */
11
+ export class SSEClient {
12
+ private eventSource: EventSource | null = null;
13
+ private subscribers: Map<string, SSECallback> = new Map();
14
+ private connected: boolean = false;
15
+ private reconnecting: boolean = false; // tracks if EventSource auto-reconnect is in progress
16
+
17
+ /**
18
+ * @param sseEndpoint - The full SSE endpoint URL (e.g., "http://127.0.0.1:8080/models/sse")
19
+ * @param apiKey - Optional API key for authenticated servers
20
+ */
21
+ constructor(
22
+ private readonly sseEndpoint: string,
23
+ private readonly apiKey?: string,
24
+ ) {}
25
+
26
+ /**
27
+ * Connects to the SSE endpoint.
28
+ *
29
+ * @returns true if the connection was established successfully
30
+ */
31
+ async connect(): Promise<boolean> {
32
+ if (this.connected) return true;
33
+
34
+ const url = this.buildUrl();
35
+
36
+ try {
37
+ this.eventSource = new EventSource(url);
38
+ } catch {
39
+ this.connected = false;
40
+ return false;
41
+ }
42
+
43
+ this.eventSource.onopen = () => {
44
+ this.connected = true;
45
+ this.reconnecting = false;
46
+ };
47
+
48
+ this.eventSource.onerror = () => {
49
+ // EventSource will auto-reconnect; we just track state
50
+ this.connected = false;
51
+ this.reconnecting = true;
52
+ };
53
+
54
+ this.eventSource.onmessage = (event: MessageEvent) => {
55
+ try {
56
+ const data = JSON.parse(event.data);
57
+ const sseEvent: SSEEvent = {
58
+ event: data.event ?? "unknown",
59
+ model: data.model ?? "*",
60
+ data: data.data,
61
+ };
62
+ this.dispatch(sseEvent);
63
+ } catch {
64
+ // Invalid JSON, ignore
65
+ }
66
+ };
67
+
68
+ // Wait a bit for the connection to establish
69
+ await new Promise<void>((resolve) => {
70
+ const timeout = setTimeout(() => resolve(), POLLING_INTERVAL);
71
+ this.eventSource!.onopen = () => {
72
+ clearTimeout(timeout);
73
+ this.connected = true;
74
+ this.reconnecting = false;
75
+ resolve();
76
+ };
77
+ });
78
+
79
+ return this.connected;
80
+ }
81
+
82
+ /**
83
+ * Subscribes to SSE events for a specific model.
84
+ * Auto-connects if not already connected.
85
+ *
86
+ * @param modelId - The model ID to subscribe to
87
+ * @param callback - Callback to receive SSE events
88
+ * @returns A cleanup function to unsubscribe
89
+ */
90
+ subscribe(modelId: string, callback: SSECallback): SSECleanup {
91
+ this.subscribers.set(modelId, callback);
92
+
93
+ if (!this.connected && !this.reconnecting) {
94
+ this.connect();
95
+ }
96
+
97
+ return () => {
98
+ this.subscribers.delete(modelId);
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Disconnects from the SSE endpoint and clears all subscriptions.
104
+ */
105
+ disconnect(): void {
106
+ if (this.eventSource) {
107
+ this.eventSource.close();
108
+ this.eventSource = null;
109
+ }
110
+ this.connected = false;
111
+ this.subscribers.clear();
112
+ }
113
+
114
+ /**
115
+ * Builds the full URL with optional API key query param.
116
+ */
117
+ private buildUrl(): string {
118
+ if (this.apiKey) {
119
+ return `${this.sseEndpoint}?api_key=${encodeURIComponent(this.apiKey)}`;
120
+ }
121
+ return this.sseEndpoint;
122
+ }
123
+
124
+ /**
125
+ * Dispatches an SSE event to all matching subscribers.
126
+ */
127
+ private dispatch(event: SSEEvent): void {
128
+ // Dispatch to model-specific subscriber
129
+ const modelCallback = this.subscribers.get(event.model);
130
+ if (modelCallback) {
131
+ modelCallback(event);
132
+ }
133
+
134
+ // Also dispatch to wildcard subscriber if present
135
+ const wildcardCallback = this.subscribers.get("*");
136
+ if (wildcardCallback && event.model !== "*") {
137
+ wildcardCallback(event);
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,202 @@
1
+ import { POLLING_TIMEOUT, SERVER_TIMEOUT } from "../constants";
2
+ import { SSEClient } from "./client";
3
+ import {
4
+ DownloadProgressData,
5
+ ProgressData,
6
+ SSECallback,
7
+ SSECleanup,
8
+ SSEEvent,
9
+ SSEEventType,
10
+ StatusChangeData,
11
+ } from "./types";
12
+
13
+ /**
14
+ * Manages SSE connections and event routing for a single llama-server instance.
15
+ *
16
+ * Handles:
17
+ * - Shared EventSource connection
18
+ * - Model-based event subscription with callback aggregation
19
+ * - Progress parsing and callback dispatch
20
+ */
21
+ export class SSEManager {
22
+ private sseClient: SSEClient | null = null;
23
+ private sseSubscribers: Map<string, SSECleanup> = new Map();
24
+ private modelCallbacks: Map<string, SSECallback[]> = new Map();
25
+ private sseSupported: boolean | null = null;
26
+
27
+ constructor(
28
+ private readonly baseUrl: string,
29
+ private readonly apiKey: string,
30
+ ) {}
31
+
32
+ /**
33
+ * The SSE endpoint URL.
34
+ */
35
+ private get sseEndpoint(): string {
36
+ return `${this.baseUrl}/models/sse`;
37
+ }
38
+
39
+ /**
40
+ * Probes the SSE endpoint to check if it's supported.
41
+ * Result is cached for the lifetime of the manager.
42
+ *
43
+ * @returns true if SSE is supported
44
+ */
45
+ async probeSSE(): Promise<boolean> {
46
+ if (this.sseSupported !== null) return this.sseSupported;
47
+
48
+ try {
49
+ const headers: Record<string, string> = {};
50
+ if (this.apiKey) {
51
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
52
+ }
53
+ const response = await fetch(this.sseEndpoint, {
54
+ method: "GET",
55
+ headers,
56
+ signal: AbortSignal.timeout(SERVER_TIMEOUT),
57
+ });
58
+ this.sseSupported =
59
+ response.ok &&
60
+ !!response.headers.get("content-type")?.includes("text/event-stream");
61
+ } catch {
62
+ this.sseSupported = false;
63
+ }
64
+
65
+ return this.sseSupported;
66
+ }
67
+
68
+ /**
69
+ * Subscribes to SSE events for a specific model.
70
+ * Uses a shared SSE connection per server.
71
+ * Aggregates multiple callbacks into one SSEClient subscription.
72
+ *
73
+ * @param modelId - The model ID to subscribe to
74
+ * @param callback - Callback to receive SSE events
75
+ * @returns A cleanup function to unsubscribe
76
+ */
77
+ private subscribeToSSE(
78
+ modelId: string,
79
+ callback: (event: SSEEvent) => void,
80
+ ): SSECleanup {
81
+ // Aggregate callbacks for this model
82
+ const callbacks = this.modelCallbacks.get(modelId) ?? [];
83
+ callbacks.push(callback);
84
+ this.modelCallbacks.set(modelId, callbacks);
85
+
86
+ // Create SSE client if not already created
87
+ this.sseClient ??= new SSEClient(this.sseEndpoint, this.apiKey);
88
+
89
+ // Subscribe a single dispatching callback to the SSE client
90
+ if (!this.sseSubscribers.has(modelId)) {
91
+ const dispatch = (event: SSEEvent) => {
92
+ for (const cb of callbacks) cb(event);
93
+ };
94
+ const cleanup = this.sseClient!.subscribe(modelId, dispatch);
95
+ this.sseSubscribers.set(modelId, cleanup);
96
+ }
97
+
98
+ return () => {
99
+ const list = this.modelCallbacks.get(modelId);
100
+ if (list) {
101
+ const idx = list.indexOf(callback);
102
+ if (idx !== -1) list.splice(idx, 1);
103
+ if (list.length === 0) {
104
+ this.modelCallbacks.delete(modelId);
105
+ const cleanup = this.sseSubscribers.get(modelId);
106
+ if (cleanup) cleanup();
107
+ this.sseSubscribers.delete(modelId);
108
+ }
109
+ }
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Subscribes to SSE progress events for a specific model.
115
+ * Parses SSE events and calls the progress callback with percentage and stage.
116
+ *
117
+ * @param modelId - The model ID to subscribe to
118
+ * @param onProgress - Callback to receive progress updates (percentage 0-100, stage name)
119
+ * @returns A cleanup function to unsubscribe
120
+ */
121
+ subscribeToProgress(
122
+ modelId: string,
123
+ onProgress: (percentage: number, stage?: string) => void,
124
+ ): SSECleanup {
125
+ // Track download progress across multiple URLs
126
+ let totalDownloaded = 0;
127
+ let totalToDownload = 0;
128
+
129
+ return this.subscribeToSSE(modelId, (event: SSEEvent) => {
130
+ if (event.event === SSEEventType.status_change && event.data) {
131
+ const data = event.data as unknown as StatusChangeData;
132
+
133
+ if (data.status === "loading" && data.progress) {
134
+ const progress = data.progress as ProgressData;
135
+ const percentage = Math.round(progress.value * 100);
136
+ onProgress(percentage, progress.current);
137
+ } else if (data.status === "loaded" || data.status === "failed") {
138
+ // Reset download tracking on final state
139
+ totalDownloaded = 0;
140
+ totalToDownload = 0;
141
+ }
142
+ } else if (event.event === SSEEventType.download_progress && event.data) {
143
+ const downloadData = event.data as DownloadProgressData;
144
+ totalDownloaded = 0;
145
+ totalToDownload = 0;
146
+
147
+ for (const urlData of Object.values(downloadData)) {
148
+ totalDownloaded += urlData.done;
149
+ totalToDownload += urlData.total;
150
+ }
151
+
152
+ if (totalToDownload > 0) {
153
+ const percentage = Math.round(
154
+ (totalDownloaded / totalToDownload) * 100,
155
+ );
156
+ onProgress(percentage, "downloading");
157
+ }
158
+ }
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Subscribes to SSE status change events for a specific model.
164
+ * Resolves with the final status string once the model reaches a terminal state.
165
+ *
166
+ * @param modelId - The model ID to subscribe to
167
+ * @returns Promise that resolves with the final status string
168
+ */
169
+ subscribeToStatus(modelId: string): Promise<StatusChangeData> {
170
+ return new Promise((resolve, reject) => {
171
+ const timeout = setTimeout(
172
+ () => reject(new Error(`SSE status timeout for model: ${modelId}`)),
173
+ POLLING_TIMEOUT,
174
+ );
175
+
176
+ this.subscribeToSSE(modelId, (event: SSEEvent) => {
177
+ if (event.event === SSEEventType.status_change && event.data) {
178
+ const data = event.data as unknown as StatusChangeData;
179
+ if (["loaded", "unloaded", "failed"].includes(data.status)) {
180
+ clearTimeout(timeout);
181
+ resolve(data);
182
+ }
183
+ }
184
+ });
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Disconnects the SSE client and cleans up all subscriptions.
190
+ */
191
+ disconnect(): void {
192
+ for (const cleanup of this.sseSubscribers.values()) {
193
+ cleanup();
194
+ }
195
+ this.sseSubscribers.clear();
196
+ this.modelCallbacks.clear();
197
+ if (this.sseClient) {
198
+ this.sseClient.disconnect();
199
+ this.sseClient = null;
200
+ }
201
+ }
202
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * SSE event types from llama-server's /models/sse endpoint
3
+ */
4
+
5
+ /**
6
+ * Possible event types from the SSE stream
7
+ */
8
+ export const SSEEventType = {
9
+ status_change: "status_change",
10
+ download_progress: "download_progress",
11
+ download_finished: "download_finished",
12
+ download_failed: "download_failed",
13
+ models_reload: "models_reload",
14
+ model_remove: "model_remove",
15
+ } as const;
16
+
17
+ export type SSEEventType = (typeof SSEEventType)[keyof typeof SSEEventType];
18
+
19
+ /**
20
+ * A parsed SSE event from the /models/sse endpoint
21
+ */
22
+ export interface SSEEvent {
23
+ event: SSEEventType;
24
+ model: string; // model ID or "*" for global events
25
+ data?: Record<string, unknown>;
26
+ }
27
+
28
+ /**
29
+ * Progress data sent during model loading
30
+ */
31
+ export interface ProgressData {
32
+ stages: string[];
33
+ current: string;
34
+ value: number; // 0.0 to 1.0
35
+ }
36
+
37
+ /**
38
+ * Data payload for status_change events
39
+ */
40
+ export interface StatusChangeData {
41
+ status: string;
42
+ exit_code?: number;
43
+ info?: Record<string, unknown>;
44
+ progress?: ProgressData;
45
+ }
46
+
47
+ /**
48
+ * Data payload for download_progress events
49
+ */
50
+ export interface DownloadProgressData {
51
+ [url: string]: {
52
+ done: number;
53
+ total: number;
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Subscriber callback type for SSE events
59
+ */
60
+ export type SSECallback = (event: SSEEvent) => void;
61
+
62
+ /**
63
+ * Cleanup function to unsubscribe from SSE events
64
+ */
65
+ export type SSECleanup = () => void;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Generic TTL cache.
3
+ * Entries expire after `ttl` milliseconds from the time they were set.
4
+ */
5
+ export class Cache {
6
+ private entries = new Map<string, { data: unknown; timestamp: number }>();
7
+
8
+ /**
9
+ * @param ttl Time-to-live in milliseconds
10
+ */
11
+ constructor(private readonly ttl: number) {}
12
+
13
+ /**
14
+ * Gets a cached value by key. Returns `undefined` if missing or expired.
15
+ */
16
+ get<T>(key: string): T | undefined {
17
+ const entry = this.entries.get(key);
18
+ if (!entry) return undefined;
19
+ if (Date.now() - entry.timestamp > this.ttl) {
20
+ this.entries.delete(key);
21
+ return undefined;
22
+ }
23
+ return entry.data as T;
24
+ }
25
+
26
+ /**
27
+ * Stores a value in the cache with the current timestamp.
28
+ */
29
+ set(key: string, data: unknown): void {
30
+ this.entries.set(key, { data, timestamp: Date.now() });
31
+ }
32
+
33
+ /**
34
+ * Clears all cached entries.
35
+ */
36
+ clear(): void {
37
+ this.entries.clear();
38
+ }
39
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Ensures only one in-flight operation exists per key.
3
+ * Concurrent callers for the same key share the same promise.
4
+ */
5
+ export class Mutex {
6
+ private promises = new Map<string, Promise<unknown>>();
7
+
8
+ /**
9
+ * Runs `fn` for the given key, or returns an existing in-flight promise.
10
+ * Concurrent callers for the same key share the same promise.
11
+ */
12
+ getOrCreate<T>(key: string, fn: () => Promise<T>): Promise<T> {
13
+ const existing = this.promises.get(key);
14
+ if (existing) {
15
+ return existing as Promise<T>;
16
+ }
17
+
18
+ const promise = fn().finally(() => {
19
+ this.promises.delete(key);
20
+ });
21
+ this.promises.set(key, promise);
22
+ return promise;
23
+ }
24
+ }