@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/README.md +21 -0
- package/README.ru.md +21 -0
- package/README.zh.md +21 -0
- package/lib/adapter-registry.js +1 -0
- package/lib/capability-prober.js +339 -0
- package/lib/client.js +318 -9
- package/lib/http.js +206 -0
- package/lib/inventory.js +1 -0
- package/lib/models.js +114 -0
- package/lib/ollama-manager.js +330 -0
- package/lib/pricing-database.js +150 -17
- package/lib/provider-service.js +43 -0
- package/lib/reconcile.js +9 -2
- package/lib/scheduler.js +7 -0
- package/lib/synchronizer-prober.js +117 -35
- package/lib/synchronizer.js +92 -1
- package/package.json +1 -1
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
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
|
+
}
|