@qvac/harness 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -8,6 +8,56 @@ While the major version is `0`, a breaking change bumps the minor version.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.6.0] - 2026-09-28
12
+
13
+ ### Added
14
+
15
+ - `loadModel({ model, agent })` loads a downloaded chat, embedding, speech or
16
+ transcription model ahead of use, under the key a request for it would load
17
+ under, so the first request finds it loaded. `unloadModel({ model })` unloads
18
+ every copy of a model once the requests holding it finish, and says whether it
19
+ could. Image and music models load for each generation and are refused. A load
20
+ or an unload counts as a call in flight for `setModelIdleTimeout`, and one that
21
+ lands during an idle unload waits for it. Also on the wire as `load-model` and
22
+ `unload-model`. (#133)
23
+
24
+ ```js
25
+ const { loaded } = await harness.loadModel({ model: QWEN_35_4B, agent: { ctxSize: 8192 } })
26
+ const { unloaded } = await harness.unloadModel({ model: QWEN_35_4B })
27
+ ```
28
+
29
+ - A chat model loaded with `loadModel` is pinned: it does not count against
30
+ `completionModelCap`, so several stay loaded at once and a request for
31
+ another model leaves them in place. A manual load never unloads another model
32
+ to fit; it returns `{ loaded: false, reason: 'memory' }` when the SDK's
33
+ `assessModelFit` says it would not fit beside what is loaded. A request always
34
+ gets its model: pinned models no reply is using give way oldest first when it would not fit,
35
+ and a request or a manual load for a model under other settings replaces its idle copy. Image and
36
+ music generation and an idle unload still clear every idle chat model. (#134)
37
+
38
+ ```js
39
+ const { loaded, reason } = await harness.loadModel({ model: QWEN_35_9B })
40
+ if (reason === 'memory') showNotice('Unload a model first')
41
+ ```
42
+
43
+ ### Changed
44
+
45
+ - A model loading or unloading in any pool emits `model` with `catalogChanged`,
46
+ not only a chat model, so `list()` reports a fresh `isLoaded` for embedding,
47
+ speech and transcription models too. (#133)
48
+ - Speech, transcription and embedding requests key their pool entry by the
49
+ model's registry src however the caller names it, so a model named once by
50
+ its catalog name and once by its src is one copy in memory. (#133)
51
+
52
+ ### Fixed
53
+
54
+ - A chat model loads when the host passes no `llmDevice`. 0.5.0 sent `device`,
55
+ `gpu_layers`, `main-gpu` and `openclCacheDir` to the SDK as `undefined`,
56
+ which the SDK spreads over its defaults: with no device the load failed with
57
+ "must specify a device: 'gpu' or 'cpu'", and with a device but no
58
+ `llmGpuLayers` the default of 99 GPU layers was lost. Unset fields are left
59
+ out again. (#137)
60
+
11
61
  ## [0.5.0] - 2026-09-25
12
62
 
13
63
  ### Added
@@ -840,7 +890,8 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
840
890
  - GPU stats are read through `bare-gpu-info`, so every dependency installs from
841
891
  the public npm registry. (#12)
842
892
 
843
- [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.5.0...HEAD
893
+ [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.6.0...HEAD
894
+ [0.6.0]: https://github.com/tetherto/qvac-harness/compare/v0.5.0...v0.6.0
844
895
  [0.5.0]: https://github.com/tetherto/qvac-harness/compare/v0.4.0...v0.5.0
845
896
  [0.4.0]: https://github.com/tetherto/qvac-harness/compare/v0.3.3...v0.4.0
846
897
  [0.3.3]: https://github.com/tetherto/qvac-harness/compare/v0.3.2...v0.3.3
package/README.md CHANGED
@@ -193,6 +193,52 @@ user's files. The model stays registered and
193
193
  lists as not cached, ready to download again. Like a catalog delete, it does not
194
194
  unload a model that is running.
195
195
 
196
+ ### Load and unload a model
197
+
198
+ ```js
199
+ import { QWEN_35_4B } from '@qvac/harness'
200
+
201
+ const { loaded } = await harness.loadModel({ model: QWEN_35_4B, agent: { ctxSize: 8192 } })
202
+ const row = (await harness.list({ cachedOnly: true })).find((model) => model.name === QWEN_35_4B)
203
+ // row.isLoaded === true, and the next completion with the same agent reuses it
204
+
205
+ const { unloaded } = await harness.unloadModel({ model: QWEN_35_4B })
206
+ ```
207
+
208
+ `loadModel` loads a downloaded model ahead of use, under the key a request for it
209
+ would load under, so the first request does not load it again. Pass the agent a
210
+ chat model will run for, since its context size and KV cache types are part of
211
+ that key. Chat, embedding, speech and transcription models stay loaded; image and
212
+ music models load for each generation and are refused.
213
+
214
+ A chat model loaded this way is pinned. It does not count against
215
+ `completionModelCap`, so several can be loaded at once, and a request loading
216
+ another model leaves it in place. A request or a manual load for the same model
217
+ under other settings replaces its idle copy rather than loading a second one. A
218
+ manual load never unloads another model to fit: `{ loaded: false, reason: 'memory' }` means the
219
+ SDK's `assessModelFit` estimate says it would not fit beside what is loaded, so
220
+ unload one first. A request always gets its model, though: when that would not
221
+ fit, pinned models no reply is using give way, oldest first. Image or music generation and an idle
222
+ unload still clear every idle chat model, pinned or not.
223
+
224
+ ```js
225
+ const big = await harness.loadModel({ model: QWEN_36_27B, agent: { ctxSize: 262144 } })
226
+ if (big.reason === 'memory') showNotice('Unload a model first')
227
+ ```
228
+
229
+ `unloadModel` waits for any request holding the model to finish, then unloads
230
+ every copy of it. `unloaded` is false when a request still held it after the
231
+ wait, so a caller that wants the model gone at once stops those requests first.
232
+ Both change `isLoaded` in the next `list()`, and emit a `model` event with
233
+ `catalogChanged` set. A load or an unload counts as a call in flight for
234
+ [`setModelIdleTimeout`](#freeing-memory-when-idle), and one that lands during an
235
+ idle unload waits for it, so a load never loads a second copy and an unload
236
+ answers once the memory is free.
237
+
238
+ `bun run model-residency` (`bare examples/model-residency/run.ts`, with
239
+ `QVAC_MODEL_CACHE_PATH` pointing at the models) runs these calls against the
240
+ models already on disk and prints what each step changed. It never downloads.
241
+
196
242
  ## Freeing memory when idle
197
243
 
198
244
  ```js