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 +349 -0
- package/README.md +9 -6
- package/package.json +3 -3
- package/src/api/client.ts +121 -0
- package/src/constants.ts +1 -1
- package/src/interfaces/endpoints/models.ts +3 -0
- package/src/interfaces/endpoints/props.ts +33 -5
- package/src/managers/command.ts +15 -1
- package/src/managers/server.ts +10 -0
- package/src/models/baseModel.ts +17 -4
- package/src/models/routerModel.ts +1 -2
- package/src/server.ts +30 -39
- package/src/sse/client.ts +140 -0
- package/src/sse/manager.ts +202 -0
- package/src/sse/types.ts +65 -0
- package/src/utils/cache.ts +39 -0
- package/src/utils/mutex.ts +24 -0
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
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
39
|
+
"@types/node": "^26.0.1",
|
|
40
40
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
41
|
-
"vitest": "^4.1.
|
|
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
|
|
47
|
+
* Timeout (ms) for server verification and SSE support probe
|
|
48
48
|
*/
|
|
49
49
|
export const SERVER_TIMEOUT = 1000;
|
|
50
50
|
|
|
@@ -2,28 +2,56 @@
|
|
|
2
2
|
* The structure of llama-server's /props endpoint
|
|
3
3
|
*/
|
|
4
4
|
export interface PropsEndpoint {
|
|
5
|
-
role
|
|
6
|
-
|
|
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
|
-
|
|
20
|
-
|
|
37
|
+
ui: boolean;
|
|
38
|
+
ui_settings: Record<string, any>;
|
|
21
39
|
chat_template: string;
|
|
22
|
-
chat_template_caps:
|
|
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 {
|
package/src/managers/command.ts
CHANGED
|
@@ -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(
|
|
185
|
+
.finally(() => {
|
|
186
|
+
cleanupProgress();
|
|
187
|
+
EventManager.resetInflightModel();
|
|
188
|
+
});
|
|
175
189
|
}
|
|
176
190
|
}
|
|
177
191
|
|
package/src/managers/server.ts
CHANGED
|
@@ -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
|
*
|
package/src/models/baseModel.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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.
|
|
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.
|
|
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.
|
|
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<
|
|
138
|
-
return await this.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/sse/types.ts
ADDED
|
@@ -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
|
+
}
|