@goodandready/dsh-model-sync 0.5.17 → 0.5.18

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/lib/http.js CHANGED
@@ -339,7 +339,25 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
339
339
  }
340
340
  }
341
341
  if (route === 'import') {
342
- if (route === 'health-latency') {
342
+ if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
343
+ try {
344
+ const body = await readJson(req)
345
+ const result = await synchronizer.importConfig(body)
346
+ return json(res, 200, result)
347
+ } catch (error) {
348
+ const code = error?.code === 'INVALID_CONFIG' || error?.code === 'INVALID_POLICY' ? 400 : 500
349
+ return json(res, code, { error: { code: error?.code ?? 'import-failed', message: error instanceof Error ? error.message : String(error) } })
350
+ }
351
+ }
352
+ if (route === 'report') {
353
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
354
+ return json(res, 200, { report: synchronizer.report() })
355
+ }
356
+ if (route === 'notifications') {
357
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
358
+ return json(res, 200, { notifications: synchronizer.notifications(normalizeNotificationRequest(req.url)) })
359
+ }
360
+ if (route === 'health-latency') {
343
361
  if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
344
362
  try {
345
363
  const urlObj = new URL(req.url, 'http://127.0.0.1')
@@ -378,62 +396,159 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
378
396
  }
379
397
  return json(res, 405, { error: { code: 'method', message: 'GET or POST only' } })
380
398
  }
381
- if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
399
+
400
+ // --- Capability Probing Routes ---
401
+ if (route === 'probe-provider') {
402
+ if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
382
403
  try {
383
404
  const body = await readJson(req)
384
- const result = await synchronizer.importConfig(body)
405
+ const provider = body?.provider
406
+ const onlyNew = Boolean(body?.onlyNew)
407
+ const concurrency = Number(body?.concurrency) || 2
408
+ if (!provider) return json(res, 400, { error: { code: 'bad-request', message: 'provider is required' } })
409
+ const result = typeof synchronizer.probeProviderCapabilities === 'function'
410
+ ? await synchronizer.probeProviderCapabilities({ provider, onlyNew, concurrency })
411
+ : { provider, results: [] }
385
412
  return json(res, 200, result)
386
413
  } catch (error) {
387
- const code = error?.code === 'INVALID_CONFIG' || error?.code === 'INVALID_POLICY' ? 400 : 500
388
- return json(res, code, { error: { code: error?.code ?? 'import-failed', message: error instanceof Error ? error.message : String(error) } })
414
+ return json(res, 500, { error: { code: 'probe-failed', message: error instanceof Error ? error.message : String(error) } })
389
415
  }
390
416
  }
391
- if (route === 'report') {
392
- if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
393
- return json(res, 200, { report: synchronizer.report() })
417
+ if (route === 'probe-all') {
418
+ if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
419
+ try {
420
+ const body = await readJson(req).catch(() => ({}))
421
+ const onlyNew = Boolean(body?.onlyNew)
422
+ const concurrency = Number(body?.concurrency) || 2
423
+ const result = typeof synchronizer.probeAllCapabilities === 'function'
424
+ ? await synchronizer.probeAllCapabilities({ onlyNew, concurrency })
425
+ : { results: [] }
426
+ return json(res, 200, result)
427
+ } catch (error) {
428
+ return json(res, 500, { error: { code: 'probe-failed', message: error instanceof Error ? error.message : String(error) } })
429
+ }
394
430
  }
395
- if (route === 'notifications') {
431
+ if (route === 'probe-status') {
396
432
  if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
397
- return json(res, 200, { notifications: synchronizer.notifications(normalizeNotificationRequest(req.url)) })
433
+ const result = typeof synchronizer.getCapabilityStatus === 'function'
434
+ ? synchronizer.getCapabilityStatus()
435
+ : { isProbing: false }
436
+ return json(res, 200, result)
398
437
  }
399
- if (route === 'health-latency') {
438
+ if (route === 'probe-verified') {
400
439
  if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
401
440
  try {
402
441
  const urlObj = new URL(req.url, 'http://127.0.0.1')
403
442
  const provider = urlObj.searchParams.get('provider') || undefined
404
- const report = typeof synchronizer.getLatencyRadar === 'function'
405
- ? synchronizer.getLatencyRadar({ provider })
406
- : { updatedAt: Date.now(), radar: [] }
407
- return json(res, 200, report)
443
+ const model = urlObj.searchParams.get('model') || undefined
444
+ const result = typeof synchronizer.getVerifiedCapabilities === 'function'
445
+ ? synchronizer.getVerifiedCapabilities({ provider, model })
446
+ : {}
447
+ return json(res, 200, { verified: result })
408
448
  } catch (error) {
409
449
  return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } })
410
450
  }
411
451
  }
412
- if (route === 'pricing') {
413
- if (req.method === 'GET') {
414
- try {
415
- const urlObj = new URL(req.url, 'http://127.0.0.1')
416
- const provider = urlObj.searchParams.get('provider') || undefined
417
- const model = urlObj.searchParams.get('model') || undefined
418
- const report = typeof synchronizer.getPricingInfo === 'function'
419
- ? synchronizer.getPricingInfo({ provider, model })
420
- : { pricing: {} }
421
- return json(res, 200, report)
422
- } catch (error) {
423
- return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } })
424
- }
452
+
453
+ // --- Ollama Management Hub Routes ---
454
+ if (route === 'ollama-ps') {
455
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
456
+ try {
457
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
458
+ const baseURL = urlObj.searchParams.get('baseURL') || undefined
459
+ const result = typeof synchronizer.fetchOllamaPs === 'function'
460
+ ? await synchronizer.fetchOllamaPs({ baseURL })
461
+ : { ok: false, running: [] }
462
+ return json(res, 200, result)
463
+ } catch (error) {
464
+ return json(res, 500, { error: { code: 'ollama-failed', message: error instanceof Error ? error.message : String(error) } })
425
465
  }
426
- if (req.method === 'POST') {
427
- try {
428
- const result = typeof synchronizer.syncPricing === 'function'
429
- ? await synchronizer.syncPricing()
430
- : { updated: 0, source: 'reference' }
431
- return json(res, 200, result)
432
- } catch (error) {
433
- return json(res, 500, { error: { code: 'sync-failed', message: error instanceof Error ? error.message : String(error) } })
434
- }
466
+ }
467
+ if (route === 'ollama-tags') {
468
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
469
+ try {
470
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
471
+ const baseURL = urlObj.searchParams.get('baseURL') || undefined
472
+ const result = typeof synchronizer.fetchOllamaTags === 'function'
473
+ ? await synchronizer.fetchOllamaTags({ baseURL })
474
+ : { ok: false, models: [] }
475
+ return json(res, 200, result)
476
+ } catch (error) {
477
+ return json(res, 500, { error: { code: 'ollama-failed', message: error instanceof Error ? error.message : String(error) } })
478
+ }
479
+ }
480
+ if (route === 'ollama-info') {
481
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
482
+ try {
483
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
484
+ const model = urlObj.searchParams.get('model')
485
+ const baseURL = urlObj.searchParams.get('baseURL') || undefined
486
+ if (!model) return json(res, 400, { error: { code: 'bad-request', message: 'model is required' } })
487
+ const result = typeof synchronizer.fetchOllamaModelInfo === 'function'
488
+ ? await synchronizer.fetchOllamaModelInfo({ model, baseURL })
489
+ : { ok: false }
490
+ return json(res, 200, result)
491
+ } catch (error) {
492
+ return json(res, 500, { error: { code: 'ollama-failed', message: error instanceof Error ? error.message : String(error) } })
493
+ }
494
+ }
495
+ if (route === 'ollama-pull') {
496
+ if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
497
+ try {
498
+ const body = await readJson(req)
499
+ const model = body?.model
500
+ const baseURL = body?.baseURL
501
+ if (!model) return json(res, 400, { error: { code: 'bad-request', message: 'model is required' } })
502
+ const result = typeof synchronizer.startOllamaPull === 'function'
503
+ ? synchronizer.startOllamaPull({ model, baseURL })
504
+ : { started: false }
505
+ return json(res, 200, result)
506
+ } catch (error) {
507
+ return json(res, 500, { error: { code: 'ollama-failed', message: error instanceof Error ? error.message : String(error) } })
508
+ }
509
+ }
510
+ if (route === 'ollama-pull-status') {
511
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
512
+ try {
513
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
514
+ const model = urlObj.searchParams.get('model') || undefined
515
+ const result = typeof synchronizer.getOllamaPullStatus === 'function'
516
+ ? synchronizer.getOllamaPullStatus(model)
517
+ : null
518
+ return json(res, 200, { status: result })
519
+ } catch (error) {
520
+ return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } })
521
+ }
522
+ }
523
+ if (route === 'ollama-unload') {
524
+ if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
525
+ try {
526
+ const body = await readJson(req)
527
+ const model = body?.model
528
+ const baseURL = body?.baseURL
529
+ if (!model) return json(res, 400, { error: { code: 'bad-request', message: 'model is required' } })
530
+ const result = typeof synchronizer.unloadOllamaModel === 'function'
531
+ ? await synchronizer.unloadOllamaModel({ model, baseURL })
532
+ : { ok: false }
533
+ return json(res, 200, result)
534
+ } catch (error) {
535
+ return json(res, 500, { error: { code: 'ollama-failed', message: error instanceof Error ? error.message : String(error) } })
536
+ }
537
+ }
538
+ if (route === 'ollama-delete') {
539
+ if (req.method !== 'POST' && req.method !== 'DELETE') return json(res, 405, { error: { code: 'method', message: 'POST or DELETE only' } })
540
+ try {
541
+ const body = await readJson(req)
542
+ const model = body?.model
543
+ const baseURL = body?.baseURL
544
+ if (!model) return json(res, 400, { error: { code: 'bad-request', message: 'model is required' } })
545
+ const result = typeof synchronizer.deleteOllamaModel === 'function'
546
+ ? await synchronizer.deleteOllamaModel({ model, baseURL })
547
+ : { ok: false }
548
+ return json(res, 200, result)
549
+ } catch (error) {
550
+ return json(res, 500, { error: { code: 'ollama-failed', message: error instanceof Error ? error.message : String(error) } })
435
551
  }
436
- return json(res, 405, { error: { code: 'method', message: 'GET or POST only' } })
437
552
  }
438
553
  if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
439
554
  let options
@@ -503,5 +618,16 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
503
618
  ctx.webServer.register({ kind: 'exact', path: BASE + '/webhook/test', handler: makeHandler('webhook-test') }),
504
619
  ctx.webServer.register({ kind: 'exact', path: BASE + '/health/latency', handler: makeHandler('health-latency') }),
505
620
  ctx.webServer.register({ kind: 'exact', path: BASE + '/pricing', handler: makeHandler('pricing') }),
621
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/probe/provider', handler: makeHandler('probe-provider') }),
622
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/probe/all', handler: makeHandler('probe-all') }),
623
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/probe/status', handler: makeHandler('probe-status') }),
624
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/probe/verified', handler: makeHandler('probe-verified') }),
625
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/ollama/ps', handler: makeHandler('ollama-ps') }),
626
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/ollama/tags', handler: makeHandler('ollama-tags') }),
627
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/ollama/info', handler: makeHandler('ollama-info') }),
628
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/ollama/pull', handler: makeHandler('ollama-pull') }),
629
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/ollama/pull/status', handler: makeHandler('ollama-pull-status') }),
630
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/ollama/unload', handler: makeHandler('ollama-unload') }),
631
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/ollama/delete', handler: makeHandler('ollama-delete') }),
506
632
  ]
507
633
  }
package/lib/inventory.js CHANGED
@@ -21,6 +21,7 @@ export const API_KEY_PROVIDER_IDS = Object.freeze([
21
21
  'moonshotai',
22
22
  'moonshotai-cn',
23
23
  'nvidia',
24
+ 'ollama',
24
25
  'openai',
25
26
  'opencode',
26
27
  'opencode-go',
@@ -0,0 +1,330 @@
1
+ export function normalizeOllamaURL(url) {
2
+ let base = typeof url === 'string' && url.trim() ? url.trim() : 'http://127.0.0.1:11434'
3
+ base = base.replace(/\/+$/, '')
4
+ if (base.endsWith('/v1')) base = base.slice(0, -3)
5
+ return base
6
+ }
7
+
8
+ export function formatBytes(bytes) {
9
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 B'
10
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
11
+ const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
12
+ const val = (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)
13
+ return `${val} ${units[i]}`
14
+ }
15
+
16
+ export function createOllamaManager({ fetchImpl = globalThis.fetch } = {}) {
17
+ const activePulls = new Map()
18
+ const pullControllers = new Map()
19
+
20
+ async function fetchOllamaPs({ baseURL, signal, timeoutMs = 5000 } = {}) {
21
+ const base = normalizeOllamaURL(baseURL)
22
+ const url = `${base}/api/ps`
23
+ const controller = new AbortController()
24
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
25
+ if (signal) signal.addEventListener('abort', () => controller.abort())
26
+
27
+ try {
28
+ const res = await fetchImpl(url, {
29
+ method: 'GET',
30
+ headers: { Accept: 'application/json' },
31
+ signal: controller.signal,
32
+ })
33
+ clearTimeout(timer)
34
+ if (!res.ok) {
35
+ return { ok: false, running: [], status: res.status, error: `HTTP ${res.status}` }
36
+ }
37
+ const data = await res.json()
38
+ const models = Array.isArray(data?.models) ? data.models : []
39
+ const running = models.map((m) => {
40
+ const sizeVram = m.size_vram || 0
41
+ const sizeTotal = m.size || 0
42
+ return {
43
+ name: m.name || m.model || '',
44
+ model: m.model || m.name || '',
45
+ size: sizeTotal,
46
+ sizeFormatted: formatBytes(sizeTotal),
47
+ sizeVram,
48
+ sizeVramFormatted: formatBytes(sizeVram),
49
+ expiresAt: m.expires_at || null,
50
+ details: {
51
+ format: m.details?.format || 'gguf',
52
+ family: m.details?.family || '',
53
+ families: m.details?.families || [],
54
+ parameterSize: m.details?.parameter_size || '',
55
+ quantizationLevel: m.details?.quantization_level || '',
56
+ },
57
+ }
58
+ })
59
+ return { ok: true, running }
60
+ } catch (err) {
61
+ clearTimeout(timer)
62
+ return { ok: false, running: [], error: err?.message || String(err) }
63
+ }
64
+ }
65
+
66
+ async function fetchOllamaTags({ baseURL, signal, timeoutMs = 5000 } = {}) {
67
+ const base = normalizeOllamaURL(baseURL)
68
+ const url = `${base}/api/tags`
69
+ const controller = new AbortController()
70
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
71
+ if (signal) signal.addEventListener('abort', () => controller.abort())
72
+
73
+ try {
74
+ const res = await fetchImpl(url, {
75
+ method: 'GET',
76
+ headers: { Accept: 'application/json' },
77
+ signal: controller.signal,
78
+ })
79
+ clearTimeout(timer)
80
+ if (!res.ok) {
81
+ return { ok: false, models: [], status: res.status, error: `HTTP ${res.status}` }
82
+ }
83
+ const data = await res.json()
84
+ const models = (Array.isArray(data?.models) ? data.models : []).map((m) => ({
85
+ name: m.name || m.model || '',
86
+ model: m.model || m.name || '',
87
+ modifiedAt: m.modified_at || '',
88
+ size: m.size || 0,
89
+ sizeFormatted: formatBytes(m.size || 0),
90
+ digest: m.digest || '',
91
+ details: {
92
+ format: m.details?.format || 'gguf',
93
+ family: m.details?.family || '',
94
+ families: m.details?.families || [],
95
+ parameterSize: m.details?.parameter_size || '',
96
+ quantizationLevel: m.details?.quantization_level || '',
97
+ },
98
+ }))
99
+ return { ok: true, models }
100
+ } catch (err) {
101
+ clearTimeout(timer)
102
+ return { ok: false, models: [], error: err?.message || String(err) }
103
+ }
104
+ }
105
+
106
+ async function fetchOllamaModelInfo({ model, baseURL, signal, timeoutMs = 5000 } = {}) {
107
+ if (!model || typeof model !== 'string') throw new Error('model is required')
108
+ const base = normalizeOllamaURL(baseURL)
109
+ const url = `${base}/api/show`
110
+ const controller = new AbortController()
111
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
112
+ if (signal) signal.addEventListener('abort', () => controller.abort())
113
+
114
+ try {
115
+ const res = await fetchImpl(url, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
118
+ body: JSON.stringify({ name: model }),
119
+ signal: controller.signal,
120
+ })
121
+ clearTimeout(timer)
122
+ if (!res.ok) {
123
+ return { ok: false, error: `HTTP ${res.status}` }
124
+ }
125
+ const data = await res.json()
126
+ const modelInfo = data?.model_info || {}
127
+ let contextWindow = 4096
128
+ for (const [k, v] of Object.entries(modelInfo)) {
129
+ if (k.endsWith('.context_length') && typeof v === 'number') {
130
+ contextWindow = v
131
+ break
132
+ }
133
+ }
134
+ return {
135
+ ok: true,
136
+ model,
137
+ details: data?.details || {},
138
+ parameters: data?.parameters || '',
139
+ template: data?.template || '',
140
+ modelfile: data?.modelfile || '',
141
+ contextWindow,
142
+ }
143
+ } catch (err) {
144
+ clearTimeout(timer)
145
+ return { ok: false, model, error: err?.message || String(err) }
146
+ }
147
+ }
148
+
149
+ async function unloadOllamaModel({ model, baseURL, signal, timeoutMs = 5000 } = {}) {
150
+ if (!model || typeof model !== 'string') throw new Error('model is required')
151
+ const base = normalizeOllamaURL(baseURL)
152
+ const url = `${base}/api/generate`
153
+ const controller = new AbortController()
154
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
155
+ if (signal) signal.addEventListener('abort', () => controller.abort())
156
+
157
+ try {
158
+ const res = await fetchImpl(url, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
161
+ body: JSON.stringify({ model, keep_alive: 0 }),
162
+ signal: controller.signal,
163
+ })
164
+ clearTimeout(timer)
165
+ if (!res.ok) {
166
+ return { ok: false, error: `HTTP ${res.status}` }
167
+ }
168
+ return { ok: true, unloaded: model, message: `Model "${model}" unloaded from VRAM` }
169
+ } catch (err) {
170
+ clearTimeout(timer)
171
+ return { ok: false, model, error: err?.message || String(err) }
172
+ }
173
+ }
174
+
175
+ async function deleteOllamaModel({ model, baseURL, signal, timeoutMs = 5000 } = {}) {
176
+ if (!model || typeof model !== 'string') throw new Error('model is required')
177
+ const base = normalizeOllamaURL(baseURL)
178
+ const url = `${base}/api/delete`
179
+ const controller = new AbortController()
180
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
181
+ if (signal) signal.addEventListener('abort', () => controller.abort())
182
+
183
+ try {
184
+ const res = await fetchImpl(url, {
185
+ method: 'DELETE',
186
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
187
+ body: JSON.stringify({ name: model }),
188
+ signal: controller.signal,
189
+ })
190
+ clearTimeout(timer)
191
+ if (!res.ok) {
192
+ return { ok: false, error: `HTTP ${res.status}` }
193
+ }
194
+ return { ok: true, deleted: model, message: `Model "${model}" deleted` }
195
+ } catch (err) {
196
+ clearTimeout(timer)
197
+ return { ok: false, model, error: err?.message || String(err) }
198
+ }
199
+ }
200
+
201
+ function startOllamaPull({ model, baseURL } = {}) {
202
+ if (!model || typeof model !== 'string') throw new Error('model is required')
203
+ const cleanModel = model.trim()
204
+ const base = normalizeOllamaURL(baseURL)
205
+
206
+ const existing = activePulls.get(cleanModel)
207
+ if (existing && !existing.done) {
208
+ return { started: false, alreadyRunning: true, status: existing }
209
+ }
210
+
211
+ const state = {
212
+ model: cleanModel,
213
+ baseURL: base,
214
+ status: 'starting',
215
+ completed: 0,
216
+ total: 0,
217
+ percent: 0,
218
+ completedFormatted: '0 B',
219
+ totalFormatted: '0 B',
220
+ done: false,
221
+ error: null,
222
+ startedAt: Date.now(),
223
+ updatedAt: Date.now(),
224
+ }
225
+
226
+ activePulls.set(cleanModel, state)
227
+ const controller = new AbortController()
228
+ pullControllers.set(cleanModel, controller)
229
+
230
+ // Execute streaming pull in background
231
+ ;(async () => {
232
+ try {
233
+ const res = await fetchImpl(`${base}/api/pull`, {
234
+ method: 'POST',
235
+ headers: { 'Content-Type': 'application/json' },
236
+ body: JSON.stringify({ name: cleanModel, stream: true }),
237
+ signal: controller.signal,
238
+ })
239
+
240
+ if (!res.ok) {
241
+ state.done = true
242
+ state.error = `HTTP ${res.status}`
243
+ state.status = 'error'
244
+ state.updatedAt = Date.now()
245
+ return
246
+ }
247
+
248
+ if (res.body && typeof res.body.getReader === 'function') {
249
+ const reader = res.body.getReader()
250
+ const decoder = new TextDecoder()
251
+ let buffer = ''
252
+
253
+ while (true) {
254
+ const { done, value } = await reader.read()
255
+ if (done) break
256
+ buffer += decoder.decode(value, { stream: true })
257
+ const lines = buffer.split('\n')
258
+ buffer = lines.pop() || ''
259
+
260
+ for (const line of lines) {
261
+ const trimmed = line.trim()
262
+ if (!trimmed) continue
263
+ try {
264
+ const chunk = JSON.parse(trimmed)
265
+ if (chunk.status) state.status = chunk.status
266
+ if (typeof chunk.completed === 'number') state.completed = chunk.completed
267
+ if (typeof chunk.total === 'number') state.total = chunk.total
268
+ if (state.total > 0) {
269
+ state.percent = Math.min(100, Math.round((state.completed / state.total) * 100))
270
+ }
271
+ state.completedFormatted = formatBytes(state.completed)
272
+ state.totalFormatted = formatBytes(state.total)
273
+ state.updatedAt = Date.now()
274
+ } catch {
275
+ // Ignore parse errors on partial stream lines
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ state.done = true
282
+ state.status = 'success'
283
+ state.percent = 100
284
+ state.updatedAt = Date.now()
285
+ } catch (err) {
286
+ state.done = true
287
+ state.status = 'error'
288
+ state.error = err?.message || String(err)
289
+ state.updatedAt = Date.now()
290
+ } finally {
291
+ pullControllers.delete(cleanModel)
292
+ }
293
+ })()
294
+
295
+ return { started: true, status: state }
296
+ }
297
+
298
+ function getOllamaPullStatus(model) {
299
+ if (model) return activePulls.get(model) || null
300
+ return Object.fromEntries(activePulls.entries())
301
+ }
302
+
303
+ function cancelOllamaPull(model) {
304
+ if (!model) return false
305
+ const ctrl = pullControllers.get(model)
306
+ if (ctrl) {
307
+ ctrl.abort()
308
+ pullControllers.delete(model)
309
+ const st = activePulls.get(model)
310
+ if (st) {
311
+ st.done = true
312
+ st.status = 'cancelled'
313
+ st.updatedAt = Date.now()
314
+ }
315
+ return true
316
+ }
317
+ return false
318
+ }
319
+
320
+ return {
321
+ fetchOllamaPs,
322
+ fetchOllamaTags,
323
+ fetchOllamaModelInfo,
324
+ unloadOllamaModel,
325
+ deleteOllamaModel,
326
+ startOllamaPull,
327
+ getOllamaPullStatus,
328
+ cancelOllamaPull,
329
+ }
330
+ }
@@ -88,6 +88,19 @@ export function createProviderService(ctx) {
88
88
  return isCuratedModel(found || { provider, id: modelId })
89
89
  }
90
90
 
91
+
92
+ const getVerifiedCapabilities = (provider, modelId) => {
93
+ try {
94
+ const runner = ctx?.reflect?.get?.('modelSyncRunner') ?? (typeof ctx?.get === 'function' ? ctx.get('modelSyncRunner') : undefined)
95
+ if (runner && typeof runner.getVerifiedCapabilities === 'function') {
96
+ return runner.getVerifiedCapabilities({ provider, model: modelId })
97
+ }
98
+ } catch {
99
+ // ignore
100
+ }
101
+ return null
102
+ }
103
+
91
104
  return Object.freeze({
92
105
  listProviders,
93
106
  getProvider,
@@ -98,5 +111,6 @@ export function createProviderService(ctx) {
98
111
  getPricing,
99
112
  getTags,
100
113
  isCurated,
114
+ getVerifiedCapabilities,
101
115
  })
102
116
  }