1201 lines
34 KiB
JavaScript
1201 lines
34 KiB
JavaScript
/**
|
|
* In-process QVAC bridge for Bare OS booter.
|
|
* Uses static bind module (packed standalone cannot resolve import('@qvac/sdk')
|
|
* or relative dynamic import targets that were never static-linked into the graph).
|
|
* Set BARE_OS_SKIP_QVAC=1 to disable (CI / pack without natives).
|
|
*/
|
|
|
|
import * as qvacSdkBind from './bare-os-qvac-sdk-bind.mjs'
|
|
import {
|
|
bareOsQvacProbeGpus,
|
|
bareOsQvacMainGpuCandidates,
|
|
bareOsQvacHardwareGpus,
|
|
bareOsQvacIsSoftwareVulkanDevice,
|
|
bareOsQvacApplyHardwareVulkanEnv,
|
|
bareOsQvacRunHostCommand,
|
|
bareOsQvacHostIsDarwin
|
|
} from './bare-os-qvac-gpu-probe.mjs'
|
|
import {
|
|
bareOsQvacDiagnoseGguf,
|
|
bareOsQvacExtractGgufPath
|
|
} from './bare-os-qvac-gguf-diagnose.mjs'
|
|
import {
|
|
BARE_OS_QVAC_MODELS_GUEST_ROOT,
|
|
bareOsQvacModelsHostCacheDir,
|
|
bareOsQvacMaterializeHdmsModelsToHost,
|
|
bareOsQvacMirrorHostModelsToHdms
|
|
} from './bare-os-qvac-models-store.mjs'
|
|
import { bareOsQvacHostModelsDir } from './paths.js'
|
|
|
|
/**
|
|
* @param {unknown} env
|
|
* @returns {boolean}
|
|
*/
|
|
export function bareOsQvacSkipFromEnv(env) {
|
|
const raw =
|
|
env && typeof env === 'object'
|
|
? /** @type {Record<string, unknown>} */ (env).BARE_OS_SKIP_QVAC
|
|
: undefined
|
|
const s = String(raw ?? '').trim().toLowerCase()
|
|
return s === '1' || s === 'true' || s === 'yes'
|
|
}
|
|
|
|
/**
|
|
* Apply Vulkan device visibility before llm-llamacpp enumerates ICDs.
|
|
* Hybrid Intel+NVIDIA laptops often list iGPU as Vulkan0; hide it with e.g. `1`.
|
|
* @param {Record<string, unknown>} env
|
|
* @param {{ forceIndex?: number | null, force?: boolean }} [opts]
|
|
* @returns {string} active GGML_VK_VISIBLE_DEVICES value (may be empty)
|
|
*/
|
|
export function bareOsQvacApplyVulkanEnv(env, opts = {}) {
|
|
const procEnv =
|
|
globalThis.process && globalThis.process.env
|
|
? globalThis.process.env
|
|
: null
|
|
if (!procEnv) return ''
|
|
const fromBare = String(env.BARE_OS_QVAC_VK_VISIBLE_DEVICES ?? '').trim()
|
|
if (fromBare) {
|
|
procEnv.GGML_VK_VISIBLE_DEVICES = fromBare
|
|
return fromBare
|
|
}
|
|
if (opts.forceIndex != null && Number.isFinite(Number(opts.forceIndex))) {
|
|
const v = String(Math.floor(Number(opts.forceIndex)))
|
|
if (opts.force || !String(procEnv.GGML_VK_VISIBLE_DEVICES || '').trim()) {
|
|
procEnv.GGML_VK_VISIBLE_DEVICES = v
|
|
}
|
|
return String(procEnv.GGML_VK_VISIBLE_DEVICES || '')
|
|
}
|
|
return String(procEnv.GGML_VK_VISIBLE_DEVICES || '')
|
|
}
|
|
|
|
/**
|
|
* Resolve llm device / main-gpu / gpu_layers from load opts + env.
|
|
* Default mainGpu is `auto` (probe highest-VRAM Vulkan device, else try heuristics, else CPU).
|
|
* @param {{
|
|
* device?: string,
|
|
* mainGpu?: string | number,
|
|
* gpuLayers?: number,
|
|
* }} o
|
|
* @param {Record<string, unknown>} env
|
|
*/
|
|
export function bareOsQvacResolveDeviceConfig(o, env) {
|
|
const envDevice = String(env.BARE_OS_QVAC_DEVICE ?? '')
|
|
.trim()
|
|
.toLowerCase()
|
|
let device = String(o.device || envDevice || 'gpu')
|
|
.trim()
|
|
.toLowerCase()
|
|
if (device !== 'cpu' && device !== 'gpu') device = 'gpu'
|
|
|
|
const envMain = String(env.BARE_OS_QVAC_MAIN_GPU ?? '').trim()
|
|
const rawMain =
|
|
o.mainGpu !== undefined && o.mainGpu !== null && String(o.mainGpu).trim() !== ''
|
|
? o.mainGpu
|
|
: envMain || 'auto'
|
|
/** @type {string | number} */
|
|
let mainGpu = 'auto'
|
|
if (typeof rawMain === 'number' && Number.isFinite(rawMain) && rawMain >= 0) {
|
|
mainGpu = Math.floor(rawMain)
|
|
} else {
|
|
const s = String(rawMain).trim().toLowerCase()
|
|
if (s === 'auto' || s === 'integrated' || s === 'dedicated') mainGpu = s
|
|
else if (/^\d+$/.test(s)) mainGpu = Number.parseInt(s, 10)
|
|
}
|
|
|
|
const envLayers = Number(env.BARE_OS_QVAC_GPU_LAYERS)
|
|
const layersRaw =
|
|
o.gpuLayers !== undefined && o.gpuLayers !== null
|
|
? Number(o.gpuLayers)
|
|
: envLayers
|
|
/** @type {number | undefined} */
|
|
let gpuLayers
|
|
if (Number.isFinite(layersRaw) && layersRaw >= 0) {
|
|
gpuLayers = Math.floor(layersRaw)
|
|
}
|
|
|
|
const noCpuFallback =
|
|
String(env.BARE_OS_QVAC_NO_CPU_FALLBACK ?? '')
|
|
.trim()
|
|
.toLowerCase() === '1' ||
|
|
String(env.BARE_OS_QVAC_NO_CPU_FALLBACK ?? '')
|
|
.trim()
|
|
.toLowerCase() === 'true'
|
|
|
|
// macOS: ggml-metal exposes a single device (index 0) and only offloads
|
|
// layers when n_gpu_layers > 0 (llama.cpp default is 0 = CPU). Pin
|
|
// mainGpu=0 and default gpu_layers to all layers so the Apple GPU is
|
|
// actually used instead of silently running CPU.
|
|
if (bareOsQvacHostIsDarwin() && device === 'gpu') {
|
|
if (typeof mainGpu !== 'number') mainGpu = 0
|
|
if (gpuLayers === undefined) gpuLayers = 999
|
|
}
|
|
|
|
return { device, mainGpu, gpuLayers, noCpuFallback }
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* tools: boolean,
|
|
* ctxSize: number,
|
|
* device: string,
|
|
* mainGpu: string | number,
|
|
* gpuLayers?: number,
|
|
* verbosity?: number
|
|
* }} p
|
|
*/
|
|
export function bareOsQvacBuildModelConfig(p) {
|
|
/** @type {Record<string, unknown>} */
|
|
const modelConfig = {
|
|
ctx_size: p.ctxSize,
|
|
device: p.device
|
|
}
|
|
// Only send tools:true — false stringifies to "false" and confuses some fabric builds.
|
|
if (p.tools) modelConfig.tools = true
|
|
if (p.device === 'gpu') {
|
|
modelConfig['main-gpu'] = p.mainGpu
|
|
}
|
|
if (p.gpuLayers !== undefined) {
|
|
modelConfig.gpu_layers = p.gpuLayers
|
|
} else if (p.device === 'cpu') {
|
|
modelConfig.gpu_layers = 0
|
|
}
|
|
const verb = Number(p.verbosity)
|
|
if (Number.isFinite(verb) && verb >= 0 && verb <= 3) {
|
|
modelConfig.verbosity = Math.floor(verb)
|
|
}
|
|
return modelConfig
|
|
}
|
|
|
|
/**
|
|
* Pick Vulkan index to pin before first native load (highest VRAM / NVIDIA heuristic).
|
|
* @param {Array<{
|
|
* index: number,
|
|
* name: string,
|
|
* memoryBytes: number,
|
|
* deviceType: string,
|
|
* source: string
|
|
* }>} ranked
|
|
* @returns {{ index: number | null, note: string }}
|
|
*/
|
|
export function bareOsQvacPickVulkanPin(ranked) {
|
|
const list = bareOsQvacHardwareGpus(Array.isArray(ranked) ? ranked : [])
|
|
const vulkanBacked = list.filter((g) =>
|
|
String(g.source || '').includes('vulkaninfo')
|
|
)
|
|
if (vulkanBacked.length) {
|
|
const best = vulkanBacked[0]
|
|
return {
|
|
index: best.index,
|
|
note:
|
|
'pin GGML_VK_VISIBLE_DEVICES=' +
|
|
best.index +
|
|
' (' +
|
|
best.name +
|
|
', ' +
|
|
Math.round(best.memoryBytes / (1024 * 1024)) +
|
|
'MiB)'
|
|
}
|
|
}
|
|
const nvidia = list.find((g) => /nvidia/i.test(String(g.name || '')))
|
|
if (nvidia) {
|
|
// After VK_LOADER_DRIVERS_SELECT=*nvidia*, NVIDIA is usually Vulkan0.
|
|
// Still try index 0 first via candidates; pin 0 when only nvidia-smi hint.
|
|
return {
|
|
index: 0,
|
|
note:
|
|
'pin GGML_VK_VISIBLE_DEVICES=0 (nvidia-smi heuristic for ' +
|
|
nvidia.name +
|
|
')'
|
|
}
|
|
}
|
|
const amd = list.find((g) =>
|
|
/amd|radeon|navi|\brx\s?\d/i.test(String(g.name || ''))
|
|
)
|
|
if (amd) {
|
|
// After RADV-only ICD select, the AMD GPU is Vulkan0.
|
|
return {
|
|
index: 0,
|
|
note:
|
|
'pin GGML_VK_VISIBLE_DEVICES=0 (AMD/RADV heuristic for ' +
|
|
amd.name +
|
|
')'
|
|
}
|
|
}
|
|
return { index: null, note: '' }
|
|
}
|
|
|
|
/**
|
|
* Flatten OpenAI nested tool defs to QVAC flat shape.
|
|
* @param {unknown[]} tools
|
|
* @returns {unknown[]}
|
|
*/
|
|
export function bareOsQvacFlattenTools(tools) {
|
|
if (!Array.isArray(tools)) return []
|
|
/** @type {unknown[]} */
|
|
const out = []
|
|
for (const t of tools) {
|
|
if (!t || typeof t !== 'object') continue
|
|
const o = /** @type {Record<string, unknown>} */ (t)
|
|
if (o.function && typeof o.function === 'object') {
|
|
const fn = /** @type {Record<string, unknown>} */ (o.function)
|
|
out.push({
|
|
type: 'function',
|
|
name: typeof fn.name === 'string' ? fn.name : '',
|
|
description: typeof fn.description === 'string' ? fn.description : '',
|
|
parameters:
|
|
fn.parameters && typeof fn.parameters === 'object'
|
|
? fn.parameters
|
|
: { type: 'object', properties: {} }
|
|
})
|
|
continue
|
|
}
|
|
if (typeof o.name === 'string') {
|
|
out.push({
|
|
type: 'function',
|
|
name: o.name,
|
|
description: typeof o.description === 'string' ? o.description : '',
|
|
parameters:
|
|
o.parameters && typeof o.parameters === 'object'
|
|
? o.parameters
|
|
: { type: 'object', properties: {} }
|
|
})
|
|
}
|
|
}
|
|
return out.filter((x) => x && typeof x === 'object' && /** @type {any} */ (x).name)
|
|
}
|
|
|
|
/**
|
|
* Map one QVAC completion event → agent onEvent payload(s).
|
|
* @param {Record<string, unknown>} ev
|
|
* @param {(e: Record<string, unknown>) => void} onEvent
|
|
* @param {{ toolIndex: number }} state
|
|
*/
|
|
export function bareOsQvacEmitAgentEvent(ev, onEvent, state) {
|
|
if (!ev || typeof ev !== 'object') return
|
|
const type = typeof ev.type === 'string' ? ev.type : ''
|
|
if (type === 'contentDelta' || type === 'token') {
|
|
const text =
|
|
typeof ev.text === 'string'
|
|
? ev.text
|
|
: typeof ev.delta === 'string'
|
|
? ev.delta
|
|
: typeof ev.content === 'string'
|
|
? ev.content
|
|
: ''
|
|
if (text) onEvent({ type: 'delta_content', content: text })
|
|
return
|
|
}
|
|
if (type === 'thinkingDelta' || type === 'reasoningDelta') {
|
|
const text =
|
|
typeof ev.text === 'string'
|
|
? ev.text
|
|
: typeof ev.delta === 'string'
|
|
? ev.delta
|
|
: ''
|
|
if (text) onEvent({ type: 'delta_reasoning', reasoning: text })
|
|
return
|
|
}
|
|
if (type === 'toolCall') {
|
|
const call =
|
|
(ev.call && typeof ev.call === 'object'
|
|
? /** @type {Record<string, unknown>} */ (ev.call)
|
|
: null) ||
|
|
(ev.toolCall && typeof ev.toolCall === 'object'
|
|
? /** @type {Record<string, unknown>} */ (ev.toolCall)
|
|
: null) ||
|
|
ev
|
|
const name =
|
|
typeof call.name === 'string'
|
|
? call.name
|
|
: typeof ev.name === 'string'
|
|
? ev.name
|
|
: ''
|
|
let args = call.arguments ?? ev.arguments ?? {}
|
|
if (args != null && typeof args !== 'string') {
|
|
try {
|
|
args = JSON.stringify(args)
|
|
} catch {
|
|
args = '{}'
|
|
}
|
|
}
|
|
const idx = state.toolIndex++
|
|
const id =
|
|
typeof call.id === 'string'
|
|
? call.id
|
|
: typeof ev.id === 'string'
|
|
? ev.id
|
|
: 'qvac_call_' + idx
|
|
onEvent({
|
|
type: 'delta_tool_calls',
|
|
tool_calls: [
|
|
{
|
|
index: idx,
|
|
id,
|
|
function: {
|
|
name,
|
|
arguments: typeof args === 'string' ? args : '{}'
|
|
}
|
|
}
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* env?: Record<string, unknown>,
|
|
* vfs?: Record<string, any> | null,
|
|
* ensureModelsDrive?: () => Promise<{
|
|
* ok: boolean,
|
|
* drive?: unknown,
|
|
* reason?: string,
|
|
* created?: boolean
|
|
* } | null | void>
|
|
* }} [opts]
|
|
*/
|
|
export function createBareOsQvacBridge(opts = {}) {
|
|
const env = opts.env && typeof opts.env === 'object' ? opts.env : {}
|
|
const vfs =
|
|
opts.vfs && typeof opts.vfs === 'object' ? opts.vfs : null
|
|
const ensureModelsDrive =
|
|
typeof opts.ensureModelsDrive === 'function'
|
|
? opts.ensureModelsDrive
|
|
: null
|
|
const skip = bareOsQvacSkipFromEnv(env)
|
|
// Prefer NVIDIA / hardware Vulkan ICDs before any native ggml load.
|
|
// macOS uses ggml-metal instead — Vulkan env prep is skipped there.
|
|
if (!bareOsQvacHostIsDarwin()) {
|
|
bareOsQvacApplyHardwareVulkanEnv(env)
|
|
bareOsQvacApplyVulkanEnv(env)
|
|
}
|
|
|
|
/** @type {null | Record<string, any>} */
|
|
let api = null
|
|
/** @type {Promise<Record<string, any>> | null} */
|
|
let apiPromise = null
|
|
/** @type {string | null} */
|
|
let modelId = null
|
|
/** @type {string | null} */
|
|
let loadedModelKey = null
|
|
/** @type {'idle' | 'downloading' | 'loading' | 'ready' | 'error' | 'unavailable'} */
|
|
let status = skip ? 'unavailable' : 'idle'
|
|
/** @type {string} */
|
|
let lastError = ''
|
|
/** @type {string | null} */
|
|
let activeRequestId = null
|
|
/** @type {string} */
|
|
let hostCacheDir = bareOsQvacHostModelsDir()
|
|
/** @type {string} */
|
|
let backendsDir = ''
|
|
|
|
function available() {
|
|
return !skip && !qvacSdkBind.qvacBindIsStub
|
|
}
|
|
|
|
function getStatus() {
|
|
return {
|
|
available: available(),
|
|
status,
|
|
modelId,
|
|
loadedModelKey,
|
|
lastError: lastError || null,
|
|
skip,
|
|
activeRequestId,
|
|
cacheDir: hostCacheDir,
|
|
hdmsPath: BARE_OS_QVAC_MODELS_GUEST_ROOT,
|
|
backendsDir: backendsDir || null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure HDMS models + materialize guest GGUFs into host cache before load.
|
|
* @returns {Promise<{ drive: unknown | null }>}
|
|
*/
|
|
async function prepareModelsStore() {
|
|
/** @type {unknown | null} */
|
|
let drive = null
|
|
if (ensureModelsDrive) {
|
|
try {
|
|
const r = await ensureModelsDrive()
|
|
if (r && typeof r === 'object' && r.ok && r.drive) drive = r.drive
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
if (vfs) {
|
|
try {
|
|
await bareOsQvacMaterializeHdmsModelsToHost({
|
|
vfs,
|
|
hostCacheDir,
|
|
drive:
|
|
drive && typeof drive === 'object'
|
|
? /** @type {{ createReadStream?: Function, get?: Function }} */ (
|
|
drive
|
|
)
|
|
: null
|
|
})
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
return { drive }
|
|
}
|
|
|
|
/**
|
|
* After download/load, mirror host cache GGUFs into HDMS.
|
|
*/
|
|
async function mirrorModelsStore() {
|
|
if (!vfs) return
|
|
try {
|
|
await bareOsQvacMirrorHostModelsToHdms({ vfs, hostCacheDir })
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
async function ensureApi() {
|
|
if (skip) {
|
|
const e = new Error('QVAC disabled (BARE_OS_SKIP_QVAC=1)')
|
|
lastError = e.message
|
|
status = 'unavailable'
|
|
throw e
|
|
}
|
|
if (api) return api
|
|
if (apiPromise) return apiPromise
|
|
|
|
apiPromise = (async () => {
|
|
try {
|
|
if (qvacSdkBind.qvacBindIsStub) {
|
|
status = 'unavailable'
|
|
lastError = 'QVAC natives not packed for this host'
|
|
throw new Error(
|
|
'QVAC natives not packed for this host; run `agent --config` and choose REST API'
|
|
)
|
|
}
|
|
api = qvacSdkBind.createBoundQvacApi()
|
|
if (api && api.__bareOsQvacCacheDir) {
|
|
hostCacheDir = bareOsQvacModelsHostCacheDir(
|
|
String(api.__bareOsQvacCacheDir)
|
|
)
|
|
}
|
|
try {
|
|
backendsDir = String(
|
|
api.__bareOsQvacBackendsDir ||
|
|
qvacSdkBind.bareOsQvacResolveBackendsDir?.() ||
|
|
''
|
|
)
|
|
} catch {
|
|
backendsDir = ''
|
|
}
|
|
lastError = ''
|
|
return api
|
|
} catch (err) {
|
|
const msg =
|
|
err && typeof err === 'object' && 'message' in err
|
|
? String(/** @type {{ message: unknown }} */ (err).message)
|
|
: String(err)
|
|
lastError = msg
|
|
if (status !== 'unavailable') status = 'error'
|
|
throw new Error('@qvac/sdk unavailable: ' + msg)
|
|
}
|
|
})()
|
|
|
|
try {
|
|
return await apiPromise
|
|
} catch (err) {
|
|
apiPromise = null
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* modelSrc: string,
|
|
* tools?: boolean,
|
|
* ctxSize?: number,
|
|
* device?: string,
|
|
* mainGpu?: string | number,
|
|
* gpuLayers?: number,
|
|
* onProgress?: (p: unknown) => void
|
|
* }} o
|
|
*/
|
|
async function loadModel(o) {
|
|
const key = String(o.modelSrc || '').trim()
|
|
if (!key) throw new Error('bareOsQvacLoadModel: modelSrc required')
|
|
|
|
const toolsEnabled = o.tools !== false
|
|
// Allow model-card windows (Qwen3 32k, Llama 3.2 1B 128k). Override with
|
|
// BARE_OS_QVAC_MAX_CTX to cap KV on small GPUs.
|
|
const maxCtxRaw = Number(env.BARE_OS_QVAC_MAX_CTX)
|
|
const maxCtx =
|
|
Number.isFinite(maxCtxRaw) && maxCtxRaw >= 2048
|
|
? Math.min(131072, Math.floor(maxCtxRaw))
|
|
: 131072
|
|
const ctxSize = Math.max(2048, Math.min(maxCtx, Number(o.ctxSize) || 32768))
|
|
const resolved = bareOsQvacResolveDeviceConfig(o, env)
|
|
const verbEnv = Number(env.BARE_OS_QVAC_VERBOSITY)
|
|
// Default quiet (0). Set BARE_OS_QVAC_VERBOSITY=1..3 for llamacpp debug.
|
|
const verbosity =
|
|
Number.isFinite(verbEnv) && verbEnv >= 0 && verbEnv <= 3
|
|
? Math.floor(verbEnv)
|
|
: 0
|
|
const keyPrefix =
|
|
key +
|
|
'|ctx=' +
|
|
ctxSize +
|
|
'|tools=' +
|
|
(toolsEnabled ? '1' : '0')
|
|
|
|
if (modelId && loadedModelKey && loadedModelKey.startsWith(keyPrefix + '|')) {
|
|
const wantCpu = resolved.device === 'cpu'
|
|
const haveCpu = loadedModelKey.includes('|dev=cpu')
|
|
const exactGpu =
|
|
loadedModelKey ===
|
|
keyPrefix + '|dev=gpu|gpu=' + String(resolved.mainGpu)
|
|
const reuse =
|
|
(wantCpu && haveCpu) ||
|
|
(!wantCpu && resolved.mainGpu === 'auto') ||
|
|
(!wantCpu && resolved.mainGpu !== 'auto' && exactGpu)
|
|
if (reuse) {
|
|
status = 'ready'
|
|
return { ok: true, modelId, reused: true }
|
|
}
|
|
}
|
|
|
|
if (modelId && api && typeof api.unloadModel === 'function') {
|
|
try {
|
|
await api.unloadModel({ modelId })
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
modelId = null
|
|
loadedModelKey = null
|
|
}
|
|
|
|
/** @type {Array<string | number>} */
|
|
let gpuCandidates = []
|
|
/** @type {string} */
|
|
let probeNote = ''
|
|
/** @type {number | null} */
|
|
let vkPin = null
|
|
|
|
const metalHost = bareOsQvacHostIsDarwin()
|
|
|
|
// Prefer hardware Vulkan ICDs + disable lavapipe BEFORE probe/ensureApi —
|
|
// backends init Vulkan once per process. Skipped on macOS (ggml-metal,
|
|
// no Vulkan runtime involved).
|
|
let hasNvidiaSmi = false
|
|
/** @type {ReturnType<typeof bareOsQvacApplyHardwareVulkanEnv>} */
|
|
let hwVk = {
|
|
note: '',
|
|
icds: [],
|
|
disabledSoftware: false,
|
|
preferNvidia: false,
|
|
preferAmd: false,
|
|
vendor: ''
|
|
}
|
|
if (!metalHost) {
|
|
try {
|
|
const smi = await bareOsQvacRunHostCommand('nvidia-smi', ['-L'], {
|
|
timeoutMs: 4000
|
|
})
|
|
hasNvidiaSmi = Boolean(smi && /GPU\s+\d+:/i.test(smi))
|
|
} catch {
|
|
hasNvidiaSmi = false
|
|
}
|
|
hwVk = bareOsQvacApplyHardwareVulkanEnv(env, { hasNvidiaSmi })
|
|
if (hwVk.note) {
|
|
probeNote = hwVk.note
|
|
}
|
|
}
|
|
|
|
const existingVk = String(
|
|
(globalThis.process && globalThis.process.env
|
|
? globalThis.process.env.GGML_VK_VISIBLE_DEVICES
|
|
: '') ||
|
|
env.BARE_OS_QVAC_VK_VISIBLE_DEVICES ||
|
|
''
|
|
).trim()
|
|
|
|
if (resolved.device === 'cpu') {
|
|
gpuCandidates = []
|
|
probeNote = 'device=cpu'
|
|
} else if (metalHost) {
|
|
// ggml-metal: single Metal device (index 0). Probe system_profiler for
|
|
// the Apple GPU name/VRAM to report; the candidate is always 0.
|
|
let metalNote = 'Metal (Apple GPU)'
|
|
try {
|
|
const ranked = await bareOsQvacProbeGpus()
|
|
const g = bareOsQvacHardwareGpus(ranked)[0] || ranked[0]
|
|
if (g) {
|
|
const vram =
|
|
g.memoryBytes > 0
|
|
? ', ' + Math.round(g.memoryBytes / (1024 * 1024)) + 'MiB'
|
|
: ''
|
|
metalNote = 'Metal (' + g.name + vram + ')'
|
|
}
|
|
} catch {
|
|
/* probe best-effort */
|
|
}
|
|
gpuCandidates = [0]
|
|
probeNote = (probeNote ? probeNote + '; ' : '') + metalNote
|
|
} else if (existingVk) {
|
|
// Caller already pinned (e.g. GGML_VK_VISIBLE_DEVICES=1) — one attempt only.
|
|
gpuCandidates = [0]
|
|
probeNote =
|
|
(probeNote ? probeNote + '; ' : '') +
|
|
'using GGML_VK_VISIBLE_DEVICES=' +
|
|
existingVk
|
|
} else if (resolved.mainGpu === 'auto') {
|
|
try {
|
|
const ranked = await bareOsQvacProbeGpus()
|
|
const hardware = bareOsQvacHardwareGpus(ranked)
|
|
const onlySoftware =
|
|
ranked.length > 0 &&
|
|
hardware.length === 0 &&
|
|
ranked.every((g) => bareOsQvacIsSoftwareVulkanDevice(g))
|
|
const pin = bareOsQvacPickVulkanPin(ranked)
|
|
vkPin = pin.index
|
|
if (pin.note) {
|
|
probeNote = (probeNote ? probeNote + '; ' : '') + pin.note
|
|
}
|
|
if (hardware.length && !pin.note) {
|
|
probeNote =
|
|
(probeNote ? probeNote + '; ' : '') +
|
|
'probed ' +
|
|
hardware
|
|
.map(
|
|
(g) =>
|
|
'GPU' +
|
|
g.index +
|
|
'=' +
|
|
g.name +
|
|
'(' +
|
|
Math.round(g.memoryBytes / (1024 * 1024)) +
|
|
'MiB)'
|
|
)
|
|
.join(', ')
|
|
}
|
|
if (
|
|
onlySoftware &&
|
|
!hwVk.preferNvidia &&
|
|
!hwVk.preferAmd &&
|
|
!hwVk.icds.length
|
|
) {
|
|
// No hardware ICD and only llvmpipe — skip fake "GPU" load.
|
|
gpuCandidates = []
|
|
probeNote =
|
|
(probeNote ? probeNote + '; ' : '') +
|
|
'only software Vulkan (llvmpipe); skipping GPU'
|
|
} else if (vkPin == null && (hwVk.preferNvidia || hwVk.preferAmd)) {
|
|
// No vulkaninfo, but we selected a single vendor ICD — that
|
|
// adapter is Vulkan0 (AMD RADV / NVIDIA after SELECT).
|
|
vkPin = 0
|
|
probeNote =
|
|
(probeNote ? probeNote + '; ' : '') +
|
|
'pin GGML_VK_VISIBLE_DEVICES=0 (' +
|
|
(hwVk.preferAmd ? 'RADV/AMD' : 'NVIDIA') +
|
|
'-only ICD select)'
|
|
gpuCandidates = [0]
|
|
} else if (vkPin != null) {
|
|
// After pin / vendor-only select, selected adapter is Vulkan0.
|
|
gpuCandidates = [0]
|
|
} else {
|
|
gpuCandidates = bareOsQvacMainGpuCandidates(ranked).slice(0, 2)
|
|
if (!probeNote) probeNote = 'no vulkan pin; trying heuristics'
|
|
}
|
|
} catch {
|
|
gpuCandidates = ['dedicated', 0]
|
|
probeNote =
|
|
(probeNote ? probeNote + '; ' : '') +
|
|
'probe failed; trying dedicated/0'
|
|
}
|
|
} else if (
|
|
typeof resolved.mainGpu === 'number' &&
|
|
Number.isFinite(resolved.mainGpu)
|
|
) {
|
|
vkPin = Math.floor(resolved.mainGpu)
|
|
probeNote =
|
|
(probeNote ? probeNote + '; ' : '') +
|
|
'pin GGML_VK_VISIBLE_DEVICES=' +
|
|
vkPin +
|
|
' (explicit)'
|
|
gpuCandidates = [0]
|
|
} else {
|
|
gpuCandidates = [resolved.mainGpu]
|
|
}
|
|
|
|
bareOsQvacApplyVulkanEnv(env, {
|
|
forceIndex: vkPin,
|
|
force: vkPin != null
|
|
})
|
|
|
|
const s = await ensureApi()
|
|
await prepareModelsStore()
|
|
status = 'downloading'
|
|
const modelSrc = s[key] || key
|
|
const onProgress = (prog) => {
|
|
const pct =
|
|
prog && typeof prog === 'object' && 'percentage' in prog
|
|
? Number(/** @type {{ percentage?: unknown }} */ (prog).percentage)
|
|
: NaN
|
|
if (Number.isFinite(pct) && pct >= 100) status = 'loading'
|
|
else status = 'downloading'
|
|
if (typeof o.onProgress === 'function') o.onProgress(prog)
|
|
}
|
|
|
|
/**
|
|
* @param {ReturnType<typeof bareOsQvacResolveDeviceConfig> & { verbosity?: number }} cfg
|
|
*/
|
|
async function tryLoad(cfg) {
|
|
const modelConfig = bareOsQvacBuildModelConfig({
|
|
tools: toolsEnabled,
|
|
ctxSize,
|
|
device: cfg.device,
|
|
mainGpu: cfg.mainGpu,
|
|
gpuLayers: cfg.gpuLayers,
|
|
verbosity
|
|
})
|
|
return s.loadModel({
|
|
modelSrc,
|
|
modelType: 'llm',
|
|
modelConfig,
|
|
onProgress
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @param {string | number} mainGpu
|
|
* @param {string} [device]
|
|
*/
|
|
function makeLoadKey(mainGpu, device) {
|
|
return (
|
|
key +
|
|
'|ctx=' +
|
|
ctxSize +
|
|
'|tools=' +
|
|
(toolsEnabled ? '1' : '0') +
|
|
'|dev=' +
|
|
device +
|
|
'|gpu=' +
|
|
String(mainGpu)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} result
|
|
*/
|
|
async function finishOk(result) {
|
|
await mirrorModelsStore()
|
|
return result
|
|
}
|
|
|
|
/** @type {string[]} */
|
|
const errors = []
|
|
if (resolved.device === 'cpu') {
|
|
try {
|
|
const id = await tryLoad({
|
|
...resolved,
|
|
device: 'cpu',
|
|
gpuLayers: 0,
|
|
mainGpu: 0
|
|
})
|
|
modelId = id
|
|
loadedModelKey = makeLoadKey('cpu', 'cpu')
|
|
status = 'ready'
|
|
lastError = ''
|
|
return await finishOk({ ok: true, modelId, reused: false, device: 'cpu' })
|
|
} catch (err) {
|
|
const msg =
|
|
err && typeof err === 'object' && 'message' in err
|
|
? String(/** @type {{ message: unknown }} */ (err).message)
|
|
: String(err)
|
|
status = 'error'
|
|
lastError = msg
|
|
throw new Error(msg)
|
|
}
|
|
}
|
|
|
|
for (const cand of gpuCandidates) {
|
|
try {
|
|
const id = await tryLoad({
|
|
...resolved,
|
|
device: 'gpu',
|
|
mainGpu: cand
|
|
})
|
|
modelId = id
|
|
loadedModelKey = makeLoadKey(cand, 'gpu')
|
|
status = 'ready'
|
|
lastError = probeNote
|
|
? 'Using main-gpu=' + String(cand) + ' (' + probeNote + ')'
|
|
: ''
|
|
return await finishOk({
|
|
ok: true,
|
|
modelId,
|
|
reused: false,
|
|
device: 'gpu',
|
|
mainGpu: cand,
|
|
probe: probeNote || undefined,
|
|
vkVisible: bareOsQvacApplyVulkanEnv(env)
|
|
})
|
|
} catch (err) {
|
|
const msg =
|
|
err && typeof err === 'object' && 'message' in err
|
|
? String(/** @type {{ message: unknown }} */ (err).message)
|
|
: String(err)
|
|
errors.push('main-gpu=' + String(cand) + ': ' + msg)
|
|
if (modelId && typeof s.unloadModel === 'function') {
|
|
try {
|
|
await s.unloadModel({ modelId })
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
modelId = null
|
|
}
|
|
}
|
|
}
|
|
|
|
const gpuMsg = errors.length ? errors.join(' | ') : 'no GPU candidates'
|
|
if (!resolved.noCpuFallback) {
|
|
try {
|
|
const id = await tryLoad({
|
|
...resolved,
|
|
device: 'cpu',
|
|
gpuLayers: 0,
|
|
mainGpu: 0
|
|
})
|
|
modelId = id
|
|
loadedModelKey = makeLoadKey('cpu', 'cpu')
|
|
status = 'ready'
|
|
lastError =
|
|
'GPU load failed; using CPU. ' +
|
|
(probeNote ? probeNote + '. ' : '') +
|
|
gpuMsg
|
|
return await finishOk({
|
|
ok: true,
|
|
modelId,
|
|
reused: false,
|
|
device: 'cpu',
|
|
fellBackToCpu: true,
|
|
gpuError: gpuMsg,
|
|
probe: probeNote || undefined
|
|
})
|
|
} catch (cpuErr) {
|
|
const cpuMsg =
|
|
cpuErr && typeof cpuErr === 'object' && 'message' in cpuErr
|
|
? String(/** @type {{ message: unknown }} */ (cpuErr).message)
|
|
: String(cpuErr)
|
|
const ggufPath =
|
|
bareOsQvacExtractGgufPath(gpuMsg) || bareOsQvacExtractGgufPath(cpuMsg)
|
|
let diag = ''
|
|
if (ggufPath) {
|
|
try {
|
|
diag = await bareOsQvacDiagnoseGguf(ggufPath)
|
|
} catch {
|
|
diag = ''
|
|
}
|
|
}
|
|
status = 'error'
|
|
lastError =
|
|
'GPU and CPU load failed. ' +
|
|
(probeNote ? probeNote + '. ' : '') +
|
|
gpuMsg +
|
|
' | cpu: ' +
|
|
cpuMsg +
|
|
(diag ? ' | ' + diag : '') +
|
|
' | tip: check native log for "no CPU backend found" / backendsDir; sha256sum the gguf'
|
|
throw new Error(lastError)
|
|
}
|
|
}
|
|
|
|
status = 'error'
|
|
lastError = gpuMsg
|
|
throw new Error(lastError)
|
|
}
|
|
|
|
async function unloadModel() {
|
|
if (!modelId || !api) {
|
|
modelId = null
|
|
loadedModelKey = null
|
|
if (!skip && status !== 'error') status = 'idle'
|
|
return { ok: true }
|
|
}
|
|
try {
|
|
if (typeof api.unloadModel === 'function') {
|
|
await api.unloadModel({ modelId })
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
modelId = null
|
|
loadedModelKey = null
|
|
status = 'idle'
|
|
return { ok: true }
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* history: unknown[],
|
|
* tools?: unknown[],
|
|
* stream?: boolean,
|
|
* captureThinking?: boolean,
|
|
* modelSrc?: string,
|
|
* toolsEnabled?: boolean,
|
|
* ctxSize?: number,
|
|
* device?: string,
|
|
* mainGpu?: string | number,
|
|
* gpuLayers?: number,
|
|
* onProgress?: (p: unknown) => void,
|
|
* onEvent?: (e: Record<string, unknown>) => void,
|
|
* signal?: AbortSignal | null
|
|
* }} o
|
|
*/
|
|
async function complete(o) {
|
|
const modelSrc = String(o.modelSrc || loadedModelKey || '').trim()
|
|
if (modelSrc) {
|
|
if (o.signal && o.signal.aborted) {
|
|
const err = new Error('aborted')
|
|
err.name = 'AbortError'
|
|
throw err
|
|
}
|
|
const loadP = loadModel({
|
|
modelSrc,
|
|
tools: o.toolsEnabled !== false,
|
|
ctxSize: o.ctxSize,
|
|
device: o.device,
|
|
mainGpu: o.mainGpu,
|
|
gpuLayers: o.gpuLayers,
|
|
onProgress: o.onProgress
|
|
})
|
|
if (o.signal) {
|
|
const sig = o.signal
|
|
await Promise.race([
|
|
loadP,
|
|
new Promise((_, reject) => {
|
|
const fail = () => {
|
|
const err = new Error('aborted')
|
|
err.name = 'AbortError'
|
|
reject(err)
|
|
}
|
|
if (sig.aborted) fail()
|
|
else sig.addEventListener('abort', fail, { once: true })
|
|
})
|
|
])
|
|
} else {
|
|
await loadP
|
|
}
|
|
if (o.signal && o.signal.aborted) {
|
|
const err = new Error('aborted')
|
|
err.name = 'AbortError'
|
|
throw err
|
|
}
|
|
}
|
|
if (!modelId) throw new Error('bareOsQvacComplete: no model loaded')
|
|
|
|
const s = await ensureApi()
|
|
const onEvent =
|
|
typeof o.onEvent === 'function' ? o.onEvent : () => {}
|
|
const toolsEnabled = o.toolsEnabled !== false
|
|
const flatTools = toolsEnabled
|
|
? bareOsQvacFlattenTools(o.tools || [])
|
|
: []
|
|
const history = Array.isArray(o.history) ? o.history : []
|
|
|
|
const run = s.completion({
|
|
modelId,
|
|
history,
|
|
stream: o.stream !== false,
|
|
tools: flatTools.length ? flatTools : undefined,
|
|
captureThinking: o.captureThinking !== false
|
|
})
|
|
activeRequestId = run.requestId || run.id || null
|
|
|
|
const toolState = { toolIndex: 0 }
|
|
let sawTools = false
|
|
const signal = o.signal || null
|
|
|
|
const onAbort = () => {
|
|
try {
|
|
if (activeRequestId && typeof s.cancel === 'function') {
|
|
s.cancel({ requestId: activeRequestId })
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
if (signal) {
|
|
if (signal.aborted) onAbort()
|
|
else signal.addEventListener('abort', onAbort, { once: true })
|
|
}
|
|
|
|
/**
|
|
* Never hang session teardown on a stuck `run.final` after cancel.
|
|
* @param {Promise<unknown> | null | undefined} p
|
|
* @param {number} ms
|
|
*/
|
|
function awaitWithAbortTimeout(p, ms) {
|
|
if (!p || typeof p.then !== 'function') return Promise.resolve(null)
|
|
if (signal && signal.aborted) return Promise.resolve(null)
|
|
return new Promise((resolve) => {
|
|
let settled = false
|
|
const done = (v) => {
|
|
if (settled) return
|
|
settled = true
|
|
resolve(v)
|
|
}
|
|
const timer = setTimeout(() => done(null), Math.max(250, ms))
|
|
p.then(
|
|
(v) => {
|
|
clearTimeout(timer)
|
|
done(v)
|
|
},
|
|
() => {
|
|
clearTimeout(timer)
|
|
done(null)
|
|
}
|
|
)
|
|
if (signal) {
|
|
const onA = () => {
|
|
clearTimeout(timer)
|
|
done(null)
|
|
}
|
|
if (signal.aborted) onA()
|
|
else signal.addEventListener('abort', onA, { once: true })
|
|
}
|
|
})
|
|
}
|
|
|
|
try {
|
|
if (signal && signal.aborted) {
|
|
onEvent({ type: 'finish', finish_reason: 'abort' })
|
|
return { ok: false, aborted: true }
|
|
}
|
|
// Honor abort during ensure/load if complete was called mid-exit.
|
|
if (modelSrc && signal && signal.aborted) {
|
|
onEvent({ type: 'finish', finish_reason: 'abort' })
|
|
return { ok: false, aborted: true }
|
|
}
|
|
|
|
if (run.events && typeof run.events[Symbol.asyncIterator] === 'function') {
|
|
for await (const ev of run.events) {
|
|
if (signal && signal.aborted) break
|
|
const rec =
|
|
ev && typeof ev === 'object'
|
|
? /** @type {Record<string, unknown>} */ (ev)
|
|
: {}
|
|
if (rec.type === 'toolCall') sawTools = true
|
|
bareOsQvacEmitAgentEvent(rec, onEvent, toolState)
|
|
}
|
|
} else if (
|
|
run.tokenStream &&
|
|
typeof run.tokenStream[Symbol.asyncIterator] === 'function'
|
|
) {
|
|
for await (const token of run.tokenStream) {
|
|
if (signal && signal.aborted) break
|
|
if (typeof token === 'string' && token) {
|
|
onEvent({ type: 'delta_content', content: token })
|
|
}
|
|
}
|
|
}
|
|
|
|
let final = null
|
|
if (signal && signal.aborted) {
|
|
onAbort()
|
|
onEvent({ type: 'finish', finish_reason: 'abort' })
|
|
return { ok: false, aborted: true }
|
|
}
|
|
if (run.final) {
|
|
try {
|
|
final = await awaitWithAbortTimeout(run.final, 4000)
|
|
} catch {
|
|
final = null
|
|
}
|
|
}
|
|
if (signal && signal.aborted) {
|
|
onEvent({ type: 'finish', finish_reason: 'abort' })
|
|
return { ok: false, aborted: true }
|
|
}
|
|
// Only emit final.toolCalls when none were streamed — QVAC often
|
|
// delivers the same calls on the event stream and again on final.
|
|
if (
|
|
!sawTools &&
|
|
toolState.toolIndex === 0 &&
|
|
final &&
|
|
Array.isArray(final.toolCalls) &&
|
|
final.toolCalls.length
|
|
) {
|
|
sawTools = true
|
|
for (const tc of final.toolCalls) {
|
|
if (!tc || typeof tc !== 'object') continue
|
|
bareOsQvacEmitAgentEvent(
|
|
{ type: 'toolCall', call: tc },
|
|
onEvent,
|
|
toolState
|
|
)
|
|
}
|
|
} else if (final && Array.isArray(final.toolCalls) && final.toolCalls.length) {
|
|
sawTools = true
|
|
}
|
|
if (final && typeof final.contentText === 'string' && final.contentText && toolState.toolIndex === 0) {
|
|
/* content already streamed via deltas in normal path */
|
|
}
|
|
onEvent({
|
|
type: 'finish',
|
|
finish_reason: sawTools || toolState.toolIndex > 0 ? 'tool_calls' : 'stop'
|
|
})
|
|
if (final && final.stats) {
|
|
onEvent({ type: 'usage', usage: final.stats })
|
|
}
|
|
return { ok: true, final }
|
|
} finally {
|
|
if (signal) {
|
|
try {
|
|
signal.removeEventListener('abort', onAbort)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
activeRequestId = null
|
|
}
|
|
}
|
|
|
|
function cancelActive() {
|
|
try {
|
|
if (activeRequestId && api && typeof api.cancel === 'function') {
|
|
api.cancel({ requestId: activeRequestId })
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Best-effort teardown for session exit (never hangs the booter).
|
|
* @param {{ timeoutMs?: number }} [opts]
|
|
*/
|
|
async function dispose(opts = {}) {
|
|
cancelActive()
|
|
const timeoutMs = Math.max(200, Number(opts.timeoutMs) || 2500)
|
|
try {
|
|
await Promise.race([
|
|
unloadModel(),
|
|
new Promise((resolve) => setTimeout(resolve, timeoutMs))
|
|
])
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
return {
|
|
available,
|
|
status: getStatus,
|
|
loadModel,
|
|
unloadModel,
|
|
complete,
|
|
cancelActive,
|
|
dispose,
|
|
flattenTools: bareOsQvacFlattenTools
|
|
}
|
|
}
|