@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.
@@ -10,7 +10,7 @@ import { type EmbedContext } from './lib/capabilities/embed.ts';
10
10
  import { type KvCacheDeletionResult } from './lib/capabilities/kv-cache.ts';
11
11
  import type { LlmGenerationParams } from './lib/models/llm-presets.ts';
12
12
  import { type SpeakContext, type SpeakResult, type SpeakStreamContext, type TranscribeContext } from './lib/capabilities/voice.ts';
13
- import type { CapabilityInfo, CredentialEntry, HarnessDiagnostics, InfraModel, SkillsOptions, SkillWriteResult } from './lib/contract-types.ts';
13
+ import type { CapabilityInfo, CredentialEntry, HarnessDiagnostics, InfraModel, LoadModelResult, SkillsOptions, SkillWriteResult, UnloadModelContext, UnloadModelResult } from './lib/contract-types.ts';
14
14
  import { type CustomModelEntry } from './lib/models/model-catalog.ts';
15
15
  import { type ListModelsOptions, type ModelInfo } from './lib/models/model-list.ts';
16
16
  import { type DeleteModelSummary } from './lib/models/model-delete.ts';
@@ -138,6 +138,8 @@ export declare class Harness extends ReadyResource {
138
138
  private _tools;
139
139
  private _sdk;
140
140
  private readonly _loads;
141
+ private readonly _pinned;
142
+ private readonly _replying;
141
143
  private readonly _modelStatus;
142
144
  private _models;
143
145
  private _embedModels;
@@ -211,6 +213,7 @@ export declare class Harness extends ReadyResource {
211
213
  setCustomModels(entries: ReadonlyArray<CustomModelEntry>): void;
212
214
  setModelIdleTimeout(ms: number): void;
213
215
  previewModelSource(context: PreviewModelSourceContext): Promise<ModelSourcePreview>;
216
+ private _assessFit;
214
217
  describe(): Promise<HarnessInfo>;
215
218
  diagnostics(): HarnessDiagnostics;
216
219
  private _loadedModels;
@@ -261,6 +264,16 @@ export declare class Harness extends ReadyResource {
261
264
  capability(): Promise<CapabilityInfo>;
262
265
  warmModel(context: WarmModelOptions): Promise<void>;
263
266
  private _warmModel;
267
+ loadModel(context: WarmModelOptions): Promise<LoadModelResult>;
268
+ private _loadModel;
269
+ unloadModel(context: UnloadModelContext): Promise<UnloadModelResult>;
270
+ private _unloadModel;
271
+ private _loadAndRelease;
272
+ private _unloadIdleGenerators;
273
+ private _fits;
274
+ private _yieldPins;
275
+ private _residency;
276
+ private _completionLoadOptions;
264
277
  invoke(context: InvokeContext): AsyncGenerator<CompletionEvent>;
265
278
  private _invoke;
266
279
  completion(context: CompletionContext): AsyncGenerator<CompletionEvent>;
package/dist-lib/index.js CHANGED
@@ -21,10 +21,11 @@ import { buildWavBuffer } from './lib/wav-encode.js';
21
21
  import { createVoiceLane, packSamples, speakOnce, speakStreamOnce, transcribeOnce, transcribeStreaming, SUPERTONIC_SAMPLE_RATE } from './lib/capabilities/voice.js';
22
22
  import { pushQueue } from './lib/queue.js';
23
23
  import { AbortedError, raceAbort } from './lib/helpers/race-abort.js';
24
- import { audioGenDownloadMetadata, customProjectionMap, defaultProjectionMap, defaultTtsFor, defaultVoiceModel, diffusionDownloadMetadata, infraModelsFor, modelNameToCapabilityProfile, musicModelSet, setCustomModels } from './lib/models/model-catalog.js';
24
+ import { audioGenDownloadMetadata, customProjectionMap, defaultProjectionMap, defaultTtsFor, defaultVoiceModel, diffusionDownloadMetadata, curatedModelAsset, infraModelsFor, modelAddonOf, modelNameToCapabilityProfile, musicModelSet, residentSrc, setCustomModels } from './lib/models/model-catalog.js';
25
25
  import { createModelStatusCache, customModelRows, invalidateModelStatusCache, mergeCustomRows, listModels } from './lib/models/model-list.js';
26
26
  import { deleteModelFiles, summarizeDelete } from './lib/models/model-delete.js';
27
- import { createEmbedPool, createLoadTracker, createModelPool, createVoicePool, awaitModelCapacity, evictIdle, evictModelWithin, isResident, leaseResidentModel, leaseCount, loadedModels, modelKey, releaseModel, resolveLoadOptions, progressState } from './lib/models/model-pool.js';
27
+ import { createEmbedPool, createLoadTracker, createModelPool, createVoicePool, awaitModelCapacity, evictIdle, evictIdleWhere, evictModel, evictModelWithin, isResident, leaseResidentModel, leaseCount, loadedModels, modelKey, modelOf, releaseModel, resolveLoadOptions, progressState } from './lib/models/model-pool.js';
28
+ import { defaultContextSize } from './lib/models/context.js';
28
29
  import { resolveMainGpu } from './lib/models/gpu-selection.js';
29
30
  import { createIdleReaper } from './lib/models/idle-unload.js';
30
31
  import { openclCacheDir } from './lib/models/opencl-cache.js';
@@ -81,6 +82,7 @@ const MUSIC_EVICT_WAIT_MS = 15_000;
81
82
  const WEDGED_EVICT_WAIT_MS = 10_000;
82
83
  const MODEL_ROOM_WAIT_MS = 300_000;
83
84
  const WARM_ROOM_WAIT_MS = 2_000;
85
+ const UNLOAD_LEASE_WAIT_MS = 10_000;
84
86
  const DEFAULT_COMPLETION_MODEL_CAP = 1;
85
87
  const WEDGED_EVICT_LEASED_MS = 120_000;
86
88
  const TTS_MODEL_CONFIG = {
@@ -93,6 +95,8 @@ export class Harness extends ReadyResource {
93
95
  _tools;
94
96
  _sdk;
95
97
  _loads = createLoadTracker();
98
+ _pinned = new Set();
99
+ _replying = [];
96
100
  _modelStatus = createModelStatusCache();
97
101
  _models;
98
102
  _embedModels;
@@ -520,19 +524,18 @@ export class Harness extends ReadyResource {
520
524
  this._idle.configure(ms);
521
525
  }
522
526
  previewModelSource(context) {
523
- return previewModelSource(context, {
524
- assessFit: async ({ sha256Checksum, name, contextTokens }) => {
525
- const sdk = await this._sdk.load();
526
- const result = await sdk.assessModelFit({
527
- models: [{ model: { sha256Checksum, name }, workload: { kind: 'llm', contextTokens } }]
528
- });
529
- const model = result.models[0];
530
- return {
531
- fit: model?.verdict ?? result.verdict,
532
- fitReasons: [...(model?.reasons ?? result.reasons)]
533
- };
534
- }
527
+ return previewModelSource(context, { assessFit: (query) => this._assessFit(query) });
528
+ }
529
+ async _assessFit({ sha256Checksum, name, contextTokens }) {
530
+ const sdk = await this._sdk.load();
531
+ const result = await sdk.assessModelFit({
532
+ models: [{ model: { sha256Checksum, name }, workload: { kind: 'llm', contextTokens } }]
535
533
  });
534
+ const model = result.models[0];
535
+ return {
536
+ fit: model?.verdict ?? result.verdict,
537
+ fitReasons: [...(model?.reasons ?? result.reasons)]
538
+ };
536
539
  }
537
540
  async describe() {
538
541
  if (!this.opened)
@@ -595,7 +598,7 @@ export class Harness extends ReadyResource {
595
598
  const key = modelKey(model, loadOpts);
596
599
  const pool = this._models;
597
600
  if (!isResident(pool, key)) {
598
- await this._makeRoomForCompletion(signal);
601
+ await this._makeRoomForCompletion(model, { ctxSize: loadOpts.ctxSize, signal });
599
602
  }
600
603
  try {
601
604
  const { modelId } = await raceAbort(pool.open(key, model, loadOpts).load(), signal);
@@ -630,7 +633,7 @@ export class Harness extends ReadyResource {
630
633
  }
631
634
  _createPool() {
632
635
  const loadSdk = this._sdk.load;
633
- return createModelPool(loadSdk, (state) => this._notifyModel(state, { languageModel: true }), {
636
+ return createModelPool(loadSdk, (state) => this._notifyModel(state), {
634
637
  deviceConfig: this._deviceConfig(),
635
638
  loads: this._loads
636
639
  });
@@ -663,11 +666,18 @@ export class Harness extends ReadyResource {
663
666
  loads: this._loads
664
667
  });
665
668
  }
666
- _notifyModel(state, { languageModel = false } = {}) {
667
- const catalogChanged = languageModel && (state.status === 'ready' || state.status === 'unloaded');
669
+ _notifyModel(state) {
670
+ const catalogChanged = state.status === 'ready' || state.status === 'unloaded';
668
671
  if (catalogChanged) {
669
672
  invalidateModelStatusCache(this._modelStatus);
670
673
  }
674
+ if (state.status === 'unloaded') {
675
+ for (const key of this._pinned) {
676
+ if (!isResident(this._models, key)) {
677
+ this._pinned.delete(key);
678
+ }
679
+ }
680
+ }
671
681
  this.emit('model', { ...state, catalogChanged, loaded: this._loadedModels() });
672
682
  }
673
683
  async _collectRetiredEmbedPool(pool) {
@@ -874,7 +884,7 @@ export class Harness extends ReadyResource {
874
884
  const pool = this._embedModels;
875
885
  const faults = this._embedFaults;
876
886
  const generation = faults.generation;
877
- const key = faults.key(model);
887
+ const key = faults.key(residentSrc(model, 'embeddings'));
878
888
  const handle = pool.open(key, model);
879
889
  let loaded = null;
880
890
  let job = null;
@@ -929,7 +939,8 @@ export class Harness extends ReadyResource {
929
939
  await this.ready();
930
940
  if (this.suspended)
931
941
  throw new Error('harness is suspended');
932
- const name = model || (await this._defaultVoiceSrc(category));
942
+ const addon = category === 'tts' ? 'tts' : 'parakeet';
943
+ const name = residentSrc(model || (await this._defaultVoiceSrc(category)), addon);
933
944
  const handle = pool.open(name, name);
934
945
  let job = null;
935
946
  let releaseAfter = null;
@@ -997,7 +1008,7 @@ export class Harness extends ReadyResource {
997
1008
  await this.ready();
998
1009
  if (this.suspended)
999
1010
  throw new Error('harness is suspended');
1000
- const name = model || (await this._defaultVoiceSrc('tts'));
1011
+ const name = residentSrc(model || (await this._defaultVoiceSrc('tts')), 'tts');
1001
1012
  const pool = this._ttsModels;
1002
1013
  const handle = pool.open(name, name);
1003
1014
  const chunks = pushQueue();
@@ -1123,29 +1134,143 @@ export class Harness extends ReadyResource {
1123
1134
  await this.ready();
1124
1135
  if (this.suspended)
1125
1136
  return;
1126
- const projectionModel = this._projectionFor(model);
1127
- const loadOpts = resolveLoadOptions({
1128
- ctxSize: agent?.ctxSize ?? undefined,
1129
- kvCacheK: agent?.kvCacheK ?? undefined,
1130
- kvCacheV: agent?.kvCacheV ?? undefined,
1131
- projectionModel
1132
- }, this._deviceConfig());
1133
- const key = modelKey(model, loadOpts);
1134
- const pool = this._models;
1135
- hlog('warm model', { model, resident: isResident(pool, key) });
1136
- if (!isResident(pool, key)) {
1137
- if (!(await this._makeRoomForCompletion(signal, WARM_ROOM_WAIT_MS))) {
1137
+ const target = this._residency(model, agent);
1138
+ const resident = isResident(target.pool, target.key);
1139
+ hlog('warm model', { model, resident });
1140
+ if (target.pool === this._models && !resident) {
1141
+ const roomed = await this._makeRoomForCompletion(model, {
1142
+ ctxSize: target.ctxSize,
1143
+ signal,
1144
+ deadlineMs: WARM_ROOM_WAIT_MS
1145
+ });
1146
+ if (!roomed) {
1138
1147
  return;
1139
1148
  }
1140
- await this._imageRuntime?.unloadIdle().catch(noop);
1149
+ await this._unloadIdleGenerators();
1150
+ }
1151
+ await this._loadAndRelease(target, signal);
1152
+ }
1153
+ loadModel(context) {
1154
+ return this._busy(() => this._loadModel(context));
1155
+ }
1156
+ async _loadModel({ model, agent, signal }) {
1157
+ if (!this.opened)
1158
+ await this.ready();
1159
+ if (this.suspended) {
1160
+ throw new Error('harness is suspended');
1161
+ }
1162
+ const target = this._residency(model, agent);
1163
+ const resident = isResident(target.pool, target.key);
1164
+ hlog('load model', { model, resident });
1165
+ if (target.pool !== this._models) {
1166
+ await this._loadAndRelease(target, signal);
1167
+ return { loaded: true };
1168
+ }
1169
+ if (!resident) {
1170
+ if (!(await this._fits(model, target.ctxSize))) {
1171
+ return { loaded: false, reason: 'memory' };
1172
+ }
1173
+ await evictIdleWhere(this._models, (key) => modelOf(key) === model && !this._replying.includes(key));
1174
+ await this._unloadIdleGenerators();
1175
+ }
1176
+ this._pinned.add(target.key);
1177
+ try {
1178
+ await this._loadAndRelease(target, signal);
1179
+ }
1180
+ catch (err) {
1181
+ this._pinned.delete(target.key);
1182
+ throw err;
1183
+ }
1184
+ return { loaded: true };
1185
+ }
1186
+ unloadModel(context) {
1187
+ return this._busy(() => this._unloadModel(context));
1188
+ }
1189
+ async _unloadModel({ model }) {
1190
+ if (!this.opened)
1191
+ await this.ready();
1192
+ const target = this._residency(model);
1193
+ const name = modelOf(target.key);
1194
+ const unloaded = await evictModel(target.pool, name, { deadlineMs: UNLOAD_LEASE_WAIT_MS });
1195
+ hlog('unload model', { model, unloaded });
1196
+ if (unloaded) {
1197
+ for (const key of this._pinned) {
1198
+ if (modelOf(key) === name) {
1199
+ this._pinned.delete(key);
1200
+ }
1201
+ }
1141
1202
  }
1203
+ return { unloaded };
1204
+ }
1205
+ async _loadAndRelease(target, signal) {
1142
1206
  try {
1143
- await raceAbort(pool.open(key, model, loadOpts).load(), signal);
1207
+ await raceAbort(target.open().load(), signal);
1144
1208
  }
1145
1209
  finally {
1146
- releaseModel(pool, key);
1210
+ releaseModel(target.pool, target.key);
1147
1211
  }
1148
1212
  }
1213
+ async _unloadIdleGenerators() {
1214
+ await this._imageRuntime?.unloadIdle().catch(noop);
1215
+ await this._musicRuntime?.unloadIdle().catch(noop);
1216
+ }
1217
+ async _fits(model, ctxSize) {
1218
+ const asset = curatedModelAsset(model, 'llm');
1219
+ if (!asset) {
1220
+ return true;
1221
+ }
1222
+ const assessed = await this._assessFit({
1223
+ sha256Checksum: asset.sha256Checksum,
1224
+ name: asset.name,
1225
+ contextTokens: ctxSize || defaultContextSize(asset)
1226
+ }).catch((err) => {
1227
+ hlog('fit estimate failed', { model, err: errorMessage(err) });
1228
+ return null;
1229
+ });
1230
+ return assessed?.fit !== 'likely-too-large';
1231
+ }
1232
+ async _yieldPins(model, ctxSize) {
1233
+ for (const key of this._pinned) {
1234
+ if (leaseCount(this._models, key) > 0 || this._replying.includes(key)) {
1235
+ continue;
1236
+ }
1237
+ if (await this._fits(model, ctxSize))
1238
+ return;
1239
+ this._pinned.delete(key);
1240
+ await evictModelWithin(this._models, key, { deadlineMs: WEDGED_EVICT_WAIT_MS });
1241
+ }
1242
+ }
1243
+ _residency(model, agent) {
1244
+ const addon = modelAddonOf(model);
1245
+ if (addon === 'llm') {
1246
+ const loadOpts = this._completionLoadOptions(model, agent);
1247
+ const key = modelKey(model, loadOpts);
1248
+ return {
1249
+ pool: this._models,
1250
+ key,
1251
+ ctxSize: loadOpts.ctxSize,
1252
+ open: () => this._models.open(key, model, loadOpts)
1253
+ };
1254
+ }
1255
+ if (addon === 'embeddings') {
1256
+ const key = this._embedFaults.key(residentSrc(model, addon));
1257
+ return { pool: this._embedModels, key, open: () => this._embedModels.open(key, model) };
1258
+ }
1259
+ if (addon === 'tts' || addon === 'parakeet') {
1260
+ const pool = addon === 'tts' ? this._ttsModels : this._transcriptionModels;
1261
+ const key = residentSrc(model, addon);
1262
+ return { pool, key, open: () => pool.open(key, key) };
1263
+ }
1264
+ throw new Error(`${model} loads for each generation and is not kept in memory`);
1265
+ }
1266
+ _completionLoadOptions(model, agent) {
1267
+ return resolveLoadOptions({
1268
+ ctxSize: agent?.ctxSize ?? undefined,
1269
+ kvCacheK: agent?.kvCacheK ?? undefined,
1270
+ kvCacheV: agent?.kvCacheV ?? undefined,
1271
+ projectionModel: this._projectionFor(model)
1272
+ }, this._deviceConfig());
1273
+ }
1149
1274
  invoke(context) {
1150
1275
  return this._busyStream(() => this._invoke(context));
1151
1276
  }
@@ -1184,27 +1309,20 @@ export class Harness extends ReadyResource {
1184
1309
  await this.ready();
1185
1310
  if (this.suspended)
1186
1311
  throw new Error('harness is suspended');
1187
- const projectionModel = this._projectionFor(model);
1188
- const loadOpts = resolveLoadOptions({
1189
- ctxSize: agent?.ctxSize ?? undefined,
1190
- kvCacheK: agent?.kvCacheK ?? undefined,
1191
- kvCacheV: agent?.kvCacheV ?? undefined,
1192
- projectionModel
1193
- }, this._deviceConfig());
1312
+ const loadOpts = this._completionLoadOptions(model, agent);
1313
+ const projectionModel = loadOpts.projectionModel;
1194
1314
  const key = modelKey(model, loadOpts);
1195
1315
  const pool = this._models;
1196
1316
  const resident = isResident(pool, key);
1197
1317
  hlog('completion start', { model, resident, projection: Boolean(projectionModel) });
1198
1318
  if (!resident) {
1199
- await this._makeRoomForCompletion(signal);
1200
- await this._imageRuntime?.unloadIdle().catch(noop);
1201
- await this._musicRuntime?.unloadIdle().catch(noop);
1319
+ await this._makeRoomForCompletion(model, { ctxSize: loadOpts.ctxSize, signal });
1320
+ await this._unloadIdleGenerators();
1202
1321
  }
1203
1322
  const acquireRound = async () => {
1204
1323
  if (!isResident(pool, key)) {
1205
- await this._makeRoomForCompletion(signal);
1206
- await this._imageRuntime?.unloadIdle().catch(noop);
1207
- await this._musicRuntime?.unloadIdle().catch(noop);
1324
+ await this._makeRoomForCompletion(model, { ctxSize: loadOpts.ctxSize, signal });
1325
+ await this._unloadIdleGenerators();
1208
1326
  }
1209
1327
  try {
1210
1328
  const { modelId } = await raceAbort(pool.open(key, model, loadOpts).load(), signal);
@@ -1228,6 +1346,7 @@ export class Harness extends ReadyResource {
1228
1346
  held = false;
1229
1347
  releaseModel(pool, key);
1230
1348
  };
1349
+ this._replying.push(key);
1231
1350
  try {
1232
1351
  const { modelId, ctxSize } = await raceAbort(handle.load(), signal);
1233
1352
  hlog('model ready', { model });
@@ -1287,6 +1406,7 @@ export class Harness extends ReadyResource {
1287
1406
  }
1288
1407
  finally {
1289
1408
  hlog('completion end', { model });
1409
+ this._replying.splice(this._replying.indexOf(key), 1);
1290
1410
  releaseHeld();
1291
1411
  void this._imageRuntime?.unloadIdle().catch(noop);
1292
1412
  void this._musicRuntime?.unloadIdle().catch(noop);
@@ -1315,11 +1435,12 @@ export class Harness extends ReadyResource {
1315
1435
  }
1316
1436
  };
1317
1437
  }
1318
- async _makeRoomForCompletion(signal, deadlineMs = MODEL_ROOM_WAIT_MS) {
1438
+ async _makeRoomForCompletion(model, { ctxSize, signal, deadlineMs = MODEL_ROOM_WAIT_MS } = {}) {
1319
1439
  const roomed = await awaitModelCapacity(this._models, {
1320
1440
  cap: this._completionModelCap,
1321
1441
  deadlineMs,
1322
- signal
1442
+ signal,
1443
+ evictable: (key) => !this._pinned.has(key) || modelOf(key) === model
1323
1444
  });
1324
1445
  if (!roomed) {
1325
1446
  hlog('model room wait gave up', {
@@ -1327,6 +1448,7 @@ export class Harness extends ReadyResource {
1327
1448
  loaded: loadedModels(this._models).length
1328
1449
  });
1329
1450
  }
1451
+ await this._yieldPins(model, ctxSize);
1330
1452
  return roomed;
1331
1453
  }
1332
1454
  async _evictWedgedModel(model, key) {
@@ -3,7 +3,7 @@ import { type Transport } from './rpc-session.ts';
3
3
  import { type KvCacheDeletionResult } from './capabilities/kv-cache.ts';
4
4
  import type { CustomModelEntry } from './models/model-catalog.ts';
5
5
  import { type ModelSourcePreview, type PreviewModelSourceContext } from './capabilities/preview-model-source.ts';
6
- import type { CapabilityInfo, CompletionContext, CredentialEntry, DetectedLanguage, DetectLanguageContext, DownloadContext, EmbedContext, HarnessContract, HarnessDiagnostics, HarnessEvent, HarnessInfo, InfraModel, InvokeContext, ListModelsOptions, ModelInfo, ResolvedSkillCatalogEntry, SkillsOptions, SkillWriteResult, SpeakContext, SpeakResult, SpeakStreamContext, ToolCatalogEntry, TranscribeContext, WarmModelContext } from './contract-types.ts';
6
+ import type { CapabilityInfo, CompletionContext, CredentialEntry, DetectedLanguage, DetectLanguageContext, DownloadContext, EmbedContext, HarnessContract, HarnessDiagnostics, HarnessEvent, HarnessInfo, InfraModel, InvokeContext, ListModelsOptions, ModelInfo, ResolvedSkillCatalogEntry, SkillsOptions, SkillWriteResult, SpeakContext, SpeakResult, SpeakStreamContext, ToolCatalogEntry, TranscribeContext, LoadModelContext, LoadModelResult, UnloadModelContext, UnloadModelResult, WarmModelContext } from './contract-types.ts';
7
7
  import { type EmbedDevice } from './models/embed-config.ts';
8
8
  import type { DeleteModelSummary } from './models/model-delete.ts';
9
9
  import type { CopyBaseResult, DeleteOverlayResult, ManagedSkill, SkillFileContent } from './tools/skills/overlay-crud.ts';
@@ -46,6 +46,8 @@ export declare class RemoteHarness extends EventEmitter implements HarnessContra
46
46
  skills(agentId?: string | null, modelName?: string | null, options?: SkillsOptions): Promise<ResolvedSkillCatalogEntry[]>;
47
47
  tools(): Promise<ToolCatalogEntry[]>;
48
48
  warmModel(context: WarmModelContext): Promise<void>;
49
+ loadModel(context: LoadModelContext): Promise<LoadModelResult>;
50
+ unloadModel(context: UnloadModelContext): Promise<UnloadModelResult>;
49
51
  list(options?: ListModelsOptions): Promise<ModelInfo[]>;
50
52
  deleteModel(name: string): Promise<DeleteModelSummary>;
51
53
  clearKvCache(): Promise<KvCacheDeletionResult>;
@@ -540,6 +540,24 @@ export class RemoteHarness extends EventEmitter {
540
540
  agent: context.agent ? knobs(context.agent) : undefined
541
541
  }), context.signal);
542
542
  }
543
+ async loadModel(context) {
544
+ const { client } = await this._ensure();
545
+ const response = await abortable(client.loadModel({
546
+ model: context.model,
547
+ agent: context.agent ? knobs(context.agent) : undefined
548
+ }), context.signal);
549
+ return {
550
+ loaded: response.loaded === true,
551
+ reason: response.reason === 'memory' ? 'memory' : undefined
552
+ };
553
+ }
554
+ async unloadModel(context) {
555
+ const { client } = await this._ensure();
556
+ const response = await client.unloadModel({
557
+ model: context.model
558
+ });
559
+ return { unloaded: response.unloaded === true };
560
+ }
543
561
  async list(options = {}) {
544
562
  const { client } = await this._ensure();
545
563
  const response = await client.listModels({
@@ -113,6 +113,17 @@ export interface WarmModelContext {
113
113
  agent?: AgentKnobs;
114
114
  signal?: CompletionContext['signal'];
115
115
  }
116
+ export type LoadModelContext = WarmModelContext;
117
+ export interface LoadModelResult {
118
+ loaded: boolean;
119
+ reason?: 'memory';
120
+ }
121
+ export interface UnloadModelContext {
122
+ model: string;
123
+ }
124
+ export interface UnloadModelResult {
125
+ unloaded: boolean;
126
+ }
116
127
  export interface DownloadContext {
117
128
  model: string;
118
129
  addon?: string;
@@ -224,6 +235,8 @@ export interface HarnessContract {
224
235
  }>;
225
236
  list?(options?: ListModelsOptions): Promise<ModelInfo[]>;
226
237
  warmModel?(context: WarmModelContext): Promise<void>;
238
+ loadModel?(context: LoadModelContext): Promise<LoadModelResult>;
239
+ unloadModel?(context: UnloadModelContext): Promise<UnloadModelResult>;
227
240
  deleteModel?(name: string): Promise<DeleteModelSummary>;
228
241
  clearKvCache?(): Promise<KvCacheDeletionResult>;
229
242
  deleteKvCache?(kvCacheKey: string): Promise<KvCacheDeletionResult>;
@@ -95,6 +95,8 @@ export interface CuratedListable {
95
95
  }
96
96
  export declare function curatedListables(): CuratedListable[];
97
97
  export declare function curatedModelAsset(name: string, addon: ModelRegistryEntry['addon']): SdkModelAsset | undefined;
98
+ export declare function modelAddonOf(model: string): ModelRegistryEntry['addon'];
99
+ export declare function residentSrc(model: string, addon: ModelRegistryEntry['addon']): string;
98
100
  export declare const CUSTOM_TASK_TYPES: readonly ['llm'];
99
101
  export type CustomTaskType = (typeof CUSTOM_TASK_TYPES)[number];
100
102
  export interface CustomModelEntry {
@@ -332,6 +332,13 @@ export function curatedListables() {
332
332
  export function curatedModelAsset(name, addon) {
333
333
  return curatedListables().find((entry) => entry.asset.addon === addon && (entry.name === name || entry.asset.name === name))?.asset;
334
334
  }
335
+ export function modelAddonOf(model) {
336
+ const known = [...curatedListables(), ...musicListables()].find((entry) => entry.name === model || entry.asset.name === model || entry.asset.src === model);
337
+ return known?.asset.addon ?? 'llm';
338
+ }
339
+ export function residentSrc(model, addon) {
340
+ return curatedModelAsset(model, addon)?.src ?? model;
341
+ }
335
342
  export const CUSTOM_TASK_TYPES = ['llm'];
336
343
  let CUSTOM_MODELS = new Map();
337
344
  export function setCustomModels(entries) {
@@ -75,6 +75,7 @@ declare class ModelPuddle extends ResourcePuddle<string, ModelHandle> {
75
75
  readonly resources: Map<string, PuddleEntry>;
76
76
  }
77
77
  type PoolLike = ModelPuddle;
78
+ export declare function modelOf(key: string): string;
78
79
  export declare function releaseModel(pool: PoolLike, key: string): void;
79
80
  export declare function loadedModels(pool: PoolLike): LoadedModel[];
80
81
  export interface ResidentLease {
@@ -86,6 +87,7 @@ export declare function leaseResidentModel(pool: PoolLike, wanted?: (model: stri
86
87
  export declare function isResident(pool: PoolLike, key: string): boolean;
87
88
  export declare function leaseCount(pool: PoolLike, key: string): number;
88
89
  export declare function evictIdle(pool: PoolLike): Promise<void>;
90
+ export declare function evictIdleWhere(pool: PoolLike, wanted: (key: string) => boolean): Promise<void>;
89
91
  export interface DeadlineOptions {
90
92
  deadlineMs: number;
91
93
  tickMs?: number;
@@ -95,9 +97,11 @@ export interface ModelCapacityOptions extends DeadlineOptions {
95
97
  signal?: {
96
98
  aborted?: boolean;
97
99
  } | undefined;
100
+ evictable?: (key: string) => boolean;
98
101
  }
99
- export declare function awaitModelCapacity(pool: PoolLike, { cap, deadlineMs, tickMs, signal }: ModelCapacityOptions): Promise<boolean>;
100
- export declare function evictModelWithin(pool: PoolLike, key: string, { deadlineMs, tickMs }: DeadlineOptions): Promise<boolean>;
102
+ export declare function awaitModelCapacity(pool: PoolLike, { cap, deadlineMs, tickMs, signal, evictable }: ModelCapacityOptions): Promise<boolean>;
103
+ export declare function evictModelWithin(pool: PoolLike, key: string, options: DeadlineOptions): Promise<boolean>;
104
+ export declare function evictModel(pool: PoolLike, model: string, options: DeadlineOptions): Promise<boolean>;
101
105
  export type LoadSdk = () => Promise<ModelLifecycleSdk>;
102
106
  export type EmbedLifecycleSdk = Pick<SdkModule, 'unloadModel' | 'modelRegistrySearch'> & Partial<CancelSdk> & {
103
107
  loadModel(params: {
@@ -69,6 +69,9 @@ class ModelPuddle extends ResourcePuddle {
69
69
  function entryOf(pool, key) {
70
70
  return pool.resources.get(key);
71
71
  }
72
+ export function modelOf(key) {
73
+ return key.split('\0')[0];
74
+ }
72
75
  export function releaseModel(pool, key) {
73
76
  if (pool.destroyed)
74
77
  return;
@@ -123,8 +126,43 @@ export async function evictIdle(pool) {
123
126
  await pool.gc();
124
127
  }
125
128
  }
126
- export async function awaitModelCapacity(pool, { cap, deadlineMs, tickMs = 250, signal }) {
127
- const roomed = () => loadedModels(pool).length < cap;
129
+ export async function evictIdleWhere(pool, wanted) {
130
+ for (let tick = 0; tick <= pool.gcIdleTicks; tick++) {
131
+ if (pool.destroyed)
132
+ return;
133
+ const held = [];
134
+ for (const entry of pool.resources.values()) {
135
+ if (entry.refs === 0 && !wanted(entry.id)) {
136
+ held.push(entry.id);
137
+ }
138
+ }
139
+ if (pool.idle === held.length) {
140
+ return;
141
+ }
142
+ for (const key of held) {
143
+ pool.open(key);
144
+ }
145
+ try {
146
+ await pool.gc();
147
+ }
148
+ finally {
149
+ for (const key of held) {
150
+ releaseSettled(pool, key);
151
+ }
152
+ }
153
+ }
154
+ }
155
+ function residentCount(pool, wanted) {
156
+ let count = 0;
157
+ for (const entry of pool.resources.values()) {
158
+ if (wanted(entry.id) && entry.value.peek() !== null) {
159
+ count++;
160
+ }
161
+ }
162
+ return count;
163
+ }
164
+ export async function awaitModelCapacity(pool, { cap, deadlineMs, tickMs = 250, signal, evictable = () => true }) {
165
+ const roomed = () => residentCount(pool, evictable) < cap;
128
166
  let done = false;
129
167
  let timer = null;
130
168
  const expired = new Promise((resolve) => {
@@ -134,7 +172,7 @@ export async function awaitModelCapacity(pool, { cap, deadlineMs, tickMs = 250,
134
172
  }, deadlineMs);
135
173
  });
136
174
  const evicting = (async () => {
137
- await evictIdle(pool);
175
+ await evictIdleWhere(pool, evictable);
138
176
  while (!roomed()) {
139
177
  if (done || signal?.aborted)
140
178
  return false;
@@ -142,7 +180,7 @@ export async function awaitModelCapacity(pool, { cap, deadlineMs, tickMs = 250,
142
180
  if (done || signal?.aborted) {
143
181
  return false;
144
182
  }
145
- await evictIdle(pool);
183
+ await evictIdleWhere(pool, evictable);
146
184
  }
147
185
  return true;
148
186
  })();
@@ -156,18 +194,36 @@ export async function awaitModelCapacity(pool, { cap, deadlineMs, tickMs = 250,
156
194
  }
157
195
  }
158
196
  }
159
- export async function evictModelWithin(pool, key, { deadlineMs, tickMs = 250 }) {
197
+ export function evictModelWithin(pool, key, options) {
198
+ return evictWhereWithin(pool, (candidate) => candidate === key, options);
199
+ }
200
+ export function evictModel(pool, model, options) {
201
+ return evictWhereWithin(pool, (key) => modelOf(key) === model, options);
202
+ }
203
+ async function evictWhereWithin(pool, wanted, { deadlineMs, tickMs = 250 }) {
204
+ let done = false;
160
205
  const evicting = (async () => {
161
- while (isResident(pool, key)) {
162
- await evictIdle(pool);
163
- if (!isResident(pool, key)) {
206
+ while (residentCount(pool, wanted) > 0) {
207
+ await evictIdleWhere(pool, wanted);
208
+ if (residentCount(pool, wanted) === 0) {
164
209
  break;
165
210
  }
166
211
  await new Promise((resolve) => setTimeout(resolve, tickMs));
212
+ if (done) {
213
+ return false;
214
+ }
167
215
  }
168
216
  return true;
169
217
  })();
170
- return raceTimeout(evicting, { ms: deadlineMs, fallback: false });
218
+ try {
219
+ return await raceTimeout(evicting, { ms: deadlineMs, fallback: false });
220
+ }
221
+ finally {
222
+ done = true;
223
+ }
224
+ }
225
+ function definedFields(config) {
226
+ return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== undefined));
171
227
  }
172
228
  function llmVerbosity() {
173
229
  const raw = Number(process.env.QVAC_LLM_VERBOSITY ?? 0);
@@ -253,7 +309,7 @@ export function createModelPool(loadSdk, notify, { deviceConfig = {}, loads = cr
253
309
  const openclCache = deviceConfig.device !== 'cpu' && deviceConfig.openclCacheDir
254
310
  ? deviceConfig.openclCacheDir()
255
311
  : undefined;
256
- const modelConfig = {
312
+ const modelConfig = definedFields({
257
313
  verbosity: llmVerbosity(),
258
314
  tools: true,
259
315
  stop_sequences: RUNAWAY_STOP_SEQUENCES,
@@ -265,7 +321,7 @@ export function createModelPool(loadSdk, notify, { deviceConfig = {}, loads = cr
265
321
  gpu_layers: deviceConfig.gpuLayers,
266
322
  'main-gpu': mainGpu,
267
323
  openclCacheDir: openclCache || undefined
268
- };
324
+ });
269
325
  const { projectionModelSrc: projection, ...loggedConfig } = modelConfig;
270
326
  hlog('llm load config', { model, projection: Boolean(projection), ...loggedConfig });
271
327
  const loadStarted = Date.now();