@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/README.md +18 -0
- package/README.ru.md +18 -0
- package/README.zh.md +18 -0
- package/lib/adapter-registry.js +1 -0
- package/lib/capability-prober.js +339 -0
- package/lib/client.js +148 -3
- package/lib/http.js +164 -38
- package/lib/inventory.js +1 -0
- package/lib/ollama-manager.js +330 -0
- package/lib/provider-service.js +14 -0
- package/lib/synchronizer.js +68 -1
- package/package.json +1 -1
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 === '
|
|
392
|
-
if (req.method !== '
|
|
393
|
-
|
|
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 === '
|
|
431
|
+
if (route === 'probe-status') {
|
|
396
432
|
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
397
|
-
|
|
433
|
+
const result = typeof synchronizer.getCapabilityStatus === 'function'
|
|
434
|
+
? synchronizer.getCapabilityStatus()
|
|
435
|
+
: { isProbing: false }
|
|
436
|
+
return json(res, 200, result)
|
|
398
437
|
}
|
|
399
|
-
if (route === '
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
@@ -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
|
+
}
|
package/lib/provider-service.js
CHANGED
|
@@ -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
|
}
|