@goodandready/dsh-model-sync 0.5.16 → 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
@@ -357,6 +357,199 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
357
357
  if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
358
358
  return json(res, 200, { notifications: synchronizer.notifications(normalizeNotificationRequest(req.url)) })
359
359
  }
360
+ if (route === 'health-latency') {
361
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
362
+ try {
363
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
364
+ const provider = urlObj.searchParams.get('provider') || undefined
365
+ const report = typeof synchronizer.getLatencyRadar === 'function'
366
+ ? synchronizer.getLatencyRadar({ provider })
367
+ : { updatedAt: Date.now(), radar: [] }
368
+ return json(res, 200, report)
369
+ } catch (error) {
370
+ return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } })
371
+ }
372
+ }
373
+ if (route === 'pricing') {
374
+ if (req.method === 'GET') {
375
+ try {
376
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
377
+ const provider = urlObj.searchParams.get('provider') || undefined
378
+ const model = urlObj.searchParams.get('model') || undefined
379
+ const report = typeof synchronizer.getPricingInfo === 'function'
380
+ ? synchronizer.getPricingInfo({ provider, model })
381
+ : { pricing: {} }
382
+ return json(res, 200, report)
383
+ } catch (error) {
384
+ return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } })
385
+ }
386
+ }
387
+ if (req.method === 'POST') {
388
+ try {
389
+ const result = typeof synchronizer.syncPricing === 'function'
390
+ ? await synchronizer.syncPricing()
391
+ : { updated: 0, source: 'reference' }
392
+ return json(res, 200, result)
393
+ } catch (error) {
394
+ return json(res, 500, { error: { code: 'sync-failed', message: error instanceof Error ? error.message : String(error) } })
395
+ }
396
+ }
397
+ return json(res, 405, { error: { code: 'method', message: 'GET or POST only' } })
398
+ }
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' } })
403
+ try {
404
+ const body = await readJson(req)
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: [] }
412
+ return json(res, 200, result)
413
+ } catch (error) {
414
+ return json(res, 500, { error: { code: 'probe-failed', message: error instanceof Error ? error.message : String(error) } })
415
+ }
416
+ }
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
+ }
430
+ }
431
+ if (route === 'probe-status') {
432
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
433
+ const result = typeof synchronizer.getCapabilityStatus === 'function'
434
+ ? synchronizer.getCapabilityStatus()
435
+ : { isProbing: false }
436
+ return json(res, 200, result)
437
+ }
438
+ if (route === 'probe-verified') {
439
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
440
+ try {
441
+ const urlObj = new URL(req.url, 'http://127.0.0.1')
442
+ const provider = urlObj.searchParams.get('provider') || undefined
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 })
448
+ } catch (error) {
449
+ return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } })
450
+ }
451
+ }
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) } })
465
+ }
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) } })
551
+ }
552
+ }
360
553
  if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
361
554
  let options
362
555
  try {
@@ -423,5 +616,18 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
423
616
  ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/read', handler: makeHandler('notification-read') }),
424
617
  ctx.webServer.register({ kind: 'exact', path: BASE + '/notifications/acknowledge', handler: makeHandler('notification-acknowledge') }),
425
618
  ctx.webServer.register({ kind: 'exact', path: BASE + '/webhook/test', handler: makeHandler('webhook-test') }),
619
+ ctx.webServer.register({ kind: 'exact', path: BASE + '/health/latency', handler: makeHandler('health-latency') }),
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') }),
426
632
  ]
427
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',
package/lib/models.js CHANGED
@@ -365,6 +365,120 @@ function normalizeReasoningConfig(provider, row, id, capabilities) {
365
365
  return source
366
366
  }
367
367
 
368
+ export function deriveModelTags(model) {
369
+ if (!model || typeof model !== "object") return []
370
+ const tags = new Set(Array.isArray(model.tags) ? model.tags.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim().toLowerCase()) : [])
371
+ const idLower = String(model.id || "").toLowerCase()
372
+ const nameLower = String(model.name || "").toLowerCase()
373
+ const provLower = String(model.provider || "").toLowerCase()
374
+ const capabilities = model.capabilities || {}
375
+
376
+ // 1. coding
377
+ if (
378
+ capabilities.code === true ||
379
+ idLower.includes("coder") ||
380
+ idLower.includes("codestral") ||
381
+ idLower.includes("starcoder") ||
382
+ idLower.includes("deepseek-coder") ||
383
+ idLower.includes("claude-3-7-sonnet") ||
384
+ idLower.includes("claude-3-5-sonnet") ||
385
+ idLower.includes("kimi-k2.7-code") ||
386
+ idLower.includes("qwen-2.5-coder") ||
387
+ idLower.includes("qwen2.5-coder") ||
388
+ (/(^|[_-])(code|dev|prog)($|[_-])/i.test(idLower)) ||
389
+ (/(^|[_-])(code|coder)($|[_-])/i.test(nameLower))
390
+ ) {
391
+ tags.add("coding")
392
+ }
393
+
394
+ // 2. reasoning
395
+ if (
396
+ model.reasoningConfig?.supportsReasoning === true ||
397
+ capabilities.reasoning === true ||
398
+ /(^|[_-])(o1|o3|r1|qwq)($|[_-])/i.test(idLower) ||
399
+ idLower.includes("reasoner") ||
400
+ idLower.includes("deepseek-r1") ||
401
+ idLower.includes("thinking") ||
402
+ idLower.includes("-r1")
403
+ ) {
404
+ tags.add("reasoning")
405
+ }
406
+
407
+ // 3. fast
408
+ if (
409
+ provLower === "groq" ||
410
+ provLower === "cerebras" ||
411
+ idLower.includes("flash") ||
412
+ idLower.includes("mini") ||
413
+ idLower.includes("instant") ||
414
+ idLower.includes("haiku") ||
415
+ idLower.includes("small") ||
416
+ idLower.includes("8b") ||
417
+ idLower.includes("3b") ||
418
+ idLower.includes("1b") ||
419
+ idLower.includes("turbo") ||
420
+ idLower.includes("lite") ||
421
+ idLower.includes("nano")
422
+ ) {
423
+ tags.add("fast")
424
+ }
425
+
426
+ // 4. vision
427
+ if (
428
+ capabilities.vision === true ||
429
+ (Array.isArray(model.input) && model.input.includes("image")) ||
430
+ idLower.includes("vision") ||
431
+ idLower.includes("vl") ||
432
+ idLower.includes("pixtral") ||
433
+ idLower.includes("gemini") ||
434
+ idLower.includes("claude-3") ||
435
+ idLower.includes("4o")
436
+ ) {
437
+ tags.add("vision")
438
+ }
439
+
440
+ // 5. long-context (>= 128k tokens)
441
+ if (typeof model.contextWindow === "number" && model.contextWindow >= 128000) {
442
+ tags.add("long-context")
443
+ }
444
+
445
+ return [...tags].sort()
446
+ }
447
+
448
+ export function isCuratedModel(model) {
449
+ if (!model || typeof model !== "object") return false
450
+ if (model.lifecycle?.deprecated) return false
451
+ const id = String(model.id || "").toLowerCase()
452
+ if (/-(202\d{5}|202\d-\d{2}-\d{2}|\d{4})$/.test(id) && !id.includes("claude-3-7") && !id.includes("claude-3-5")) {
453
+ return false
454
+ }
455
+ if (id.includes("-0314") || id.includes("-0613") || id.includes("-1106") || id.includes("-0125")) {
456
+ return false
457
+ }
458
+
459
+ const curatedIds = [
460
+ "claude-3-7-sonnet", "claude-3-5-sonnet", "claude-3-5-haiku",
461
+ "gpt-4o", "gpt-4o-mini", "o1", "o3-mini",
462
+ "deepseek-chat", "deepseek-reasoner", "deepseek-v3", "deepseek-r1",
463
+ "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash",
464
+ "llama-3.3-70b-versatile", "llama-3.1-8b-instant", "llama-3.1-70b-versatile",
465
+ "qwen-2.5-coder-32b", "qwen-2.5-72b", "qwq-32b",
466
+ "mistral-large", "codestral", "pixtral-large",
467
+ "kimi-k2.7-code"
468
+ ]
469
+
470
+ for (const c of curatedIds) {
471
+ if (id === c || id.endsWith("/" + c) || id.includes(c)) return true
472
+ }
473
+
474
+ const tags = model.tags || deriveModelTags(model)
475
+ if (tags.includes("coding") && tags.includes("reasoning") && tags.includes("long-context")) {
476
+ return true
477
+ }
478
+
479
+ return false
480
+ }
481
+
368
482
  export function normalizeModel(provider, row) {
369
483
  if (typeof row === 'string') {
370
484
  row = { id: row }
@@ -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
+ }