429 lines
13 KiB
JavaScript
429 lines
13 KiB
JavaScript
/**
|
|
* Static @qvac/sdk binding for Bare standalone pack + in-process Bare.
|
|
*
|
|
* Imported only via relative dynamic `import('./bare-os-qvac-sdk-bind.mjs')` so
|
|
* booter startup does not evaluate the SDK (avoids CodeRange OOM). bare-pack
|
|
* still embeds this module because pack-anchor / host reference the literal path.
|
|
*
|
|
* When packing with --skip-qvac, `@qvac/sdk` resolves to build/stubs (see bare-standalone).
|
|
*/
|
|
import * as sdkNs from '@qvac/sdk'
|
|
import { llmPlugin } from '@qvac/sdk/llamacpp-completion/plugin'
|
|
import LlmLlamacpp from '@qvac/llm-llamacpp'
|
|
import barePath from 'bare-path'
|
|
import hostFs from '#host-fs'
|
|
import { createRequire } from 'module'
|
|
import { bareOsQvacPrepareHostCacheConfig } from './bare-os-qvac-models-store.mjs'
|
|
import {
|
|
bareOsQvacHostBackendsRoot,
|
|
bundledExecCandidates
|
|
} from './paths.js'
|
|
|
|
const requireFromBind = createRequire(import.meta.url)
|
|
|
|
/** Well-known ggml backend filenames when bundle readdir fails. */
|
|
const KNOWN_GGML_BACKEND_NAMES = [
|
|
'libqvac-ggml-cpu-x64.so',
|
|
'libqvac-ggml-cpu-sse42.so',
|
|
'libqvac-ggml-cpu-haswell.so',
|
|
'libqvac-ggml-cpu-ivybridge.so',
|
|
'libqvac-ggml-cpu-sandybridge.so',
|
|
'libqvac-ggml-cpu-skylake.so',
|
|
'libqvac-ggml-cpu-skylakex.so',
|
|
'libqvac-ggml-cpu-alderlake.so',
|
|
'libqvac-ggml-cpu-cannonlake.so',
|
|
'libqvac-ggml-cpu-cascadelake.so',
|
|
'libqvac-ggml-cpu-cooperlake.so',
|
|
'libqvac-ggml-cpu-icelake.so',
|
|
'libqvac-ggml-cpu-sapphirerapids.so',
|
|
'libqvac-ggml-cpu-piledriver.so',
|
|
'libqvac-ggml-cpu-zen4.so',
|
|
'libqvac-ggml-vulkan.so',
|
|
'libqvac-ggml-cpu.dylib',
|
|
'libqvac-ggml-metal.dylib',
|
|
'qvac-ggml-cpu.dll',
|
|
'qvac-ggml-vulkan.dll'
|
|
]
|
|
|
|
/**
|
|
* @param {string} probeDir absolute path to `<host>/qvac__llm-llamacpp`
|
|
* @returns {boolean}
|
|
*/
|
|
function backendsSlotHasCpu(probeDir) {
|
|
try {
|
|
if (!hostFs.existsSync(probeDir)) return false
|
|
return hostFs
|
|
.readdirSync(probeDir)
|
|
.some((n) => typeof n === 'string' && /ggml-cpu/i.test(n))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} root prebuilds-equivalent root (contains `<host>/qvac__llm-llamacpp`)
|
|
* @param {string} slot `<host>/qvac__llm-llamacpp`
|
|
* @returns {string} root if valid, else ''
|
|
*/
|
|
function acceptBackendsRoot(root, slot) {
|
|
if (!root) return ''
|
|
const abs = barePath.resolve(root)
|
|
return backendsSlotHasCpu(barePath.join(abs, slot)) ? abs : ''
|
|
}
|
|
|
|
/**
|
|
* Copy ggml backends from srcDir into host cache; return host root or ''.
|
|
* @param {string} srcDir
|
|
* @param {string} slot
|
|
* @param {string[]} [names]
|
|
* @returns {string}
|
|
*/
|
|
function materializeBackendsToHost(srcDir, slot, names) {
|
|
/** @type {string[]} */
|
|
let fileNames = names && names.length ? names.slice() : []
|
|
if (!fileNames.length) {
|
|
try {
|
|
fileNames = hostFs
|
|
.readdirSync(srcDir)
|
|
.filter((n) => typeof n === 'string' && /\.(so|dylib|dll)$/i.test(n))
|
|
} catch {
|
|
fileNames = []
|
|
}
|
|
}
|
|
if (!fileNames.length) {
|
|
for (const name of KNOWN_GGML_BACKEND_NAMES) {
|
|
try {
|
|
if (hostFs.existsSync(barePath.join(srcDir, name))) fileNames.push(name)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
if (!fileNames.length) return ''
|
|
|
|
const hostRoot = bareOsQvacHostBackendsRoot()
|
|
const destDir = barePath.join(hostRoot, slot)
|
|
try {
|
|
hostFs.mkdirSync(destDir, { recursive: true })
|
|
} catch {
|
|
return ''
|
|
}
|
|
|
|
for (const name of fileNames) {
|
|
const from = barePath.join(srcDir, name)
|
|
const to = barePath.join(destDir, name)
|
|
try {
|
|
let srcSize = -1
|
|
try {
|
|
srcSize = hostFs.statSync(from).size
|
|
} catch {
|
|
continue
|
|
}
|
|
try {
|
|
if (hostFs.statSync(to).size === srcSize) continue
|
|
} catch {
|
|
/* copy */
|
|
}
|
|
if (typeof hostFs.copyFileSync === 'function') {
|
|
try {
|
|
hostFs.copyFileSync(from, to)
|
|
continue
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
hostFs.writeFileSync(to, hostFs.readFileSync(from))
|
|
} catch {
|
|
/* best-effort per file */
|
|
}
|
|
}
|
|
return acceptBackendsRoot(hostRoot, slot)
|
|
}
|
|
|
|
/**
|
|
* Prefer Bare `require.asset()` so packed `/app.bundle` assets extract to real paths.
|
|
* @param {string} host
|
|
* @param {string} slot
|
|
* @returns {string}
|
|
*/
|
|
function materializeBackendsViaAsset(host, slot) {
|
|
let llmReq
|
|
try {
|
|
const main = requireFromBind.resolve('@qvac/llm-llamacpp')
|
|
llmReq = createRequire(main)
|
|
} catch {
|
|
return ''
|
|
}
|
|
if (typeof llmReq.asset !== 'function') return ''
|
|
|
|
const hostRoot = bareOsQvacHostBackendsRoot()
|
|
const destDir = barePath.join(hostRoot, slot)
|
|
try {
|
|
hostFs.mkdirSync(destDir, { recursive: true })
|
|
} catch {
|
|
return ''
|
|
}
|
|
|
|
let wrote = 0
|
|
/** @type {string} */
|
|
let realAssetDir = ''
|
|
for (const name of KNOWN_GGML_BACKEND_NAMES) {
|
|
const rel = `./prebuilds/${host}/qvac__llm-llamacpp/${name}`
|
|
try {
|
|
const assetPath = String(llmReq.asset(rel) || '')
|
|
if (!assetPath) continue
|
|
const bundleLike = /(?:^|\/)app\.bundle(?:\/|$)/i.test(assetPath)
|
|
try {
|
|
const buf = hostFs.readFileSync(assetPath)
|
|
if (buf && buf.byteLength) {
|
|
hostFs.writeFileSync(barePath.join(destDir, name), buf)
|
|
wrote += 1
|
|
if (!bundleLike) realAssetDir = barePath.dirname(assetPath)
|
|
}
|
|
} catch {
|
|
if (!bundleLike) {
|
|
try {
|
|
if (typeof hostFs.copyFileSync === 'function') {
|
|
hostFs.copyFileSync(assetPath, barePath.join(destDir, name))
|
|
wrote += 1
|
|
realAssetDir = barePath.dirname(assetPath)
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* asset missing for this name */
|
|
}
|
|
}
|
|
void wrote
|
|
|
|
const hostHit = acceptBackendsRoot(hostRoot, slot)
|
|
if (hostHit) return hostHit
|
|
|
|
// If assets landed in a real prebuilds tree, use that root directly.
|
|
if (realAssetDir && backendsSlotHasCpu(realAssetDir)) {
|
|
const prebuildsRoot = barePath.resolve(realAssetDir, '..', '..')
|
|
const hit = acceptBackendsRoot(prebuildsRoot, slot)
|
|
if (hit) return hit
|
|
}
|
|
return ''
|
|
}
|
|
|
|
const sdk =
|
|
sdkNs?.default && typeof sdkNs.default === 'object'
|
|
? { ...sdkNs, ...sdkNs.default }
|
|
: sdkNs
|
|
|
|
/** True when pack remapped @qvac/sdk to the CI stub (e.g. win32-arm64). */
|
|
export const qvacBindIsStub = Boolean(
|
|
sdk &&
|
|
(sdk.BARE_OS_QVAC_SDK_STUB === true ||
|
|
(llmPlugin &&
|
|
typeof llmPlugin === 'object' &&
|
|
/** @type {{ name?: string }} */ (llmPlugin).name === 'qvac-llm-plugin-stub'))
|
|
)
|
|
|
|
let llmLoadPatched = false
|
|
|
|
/**
|
|
* Host id matching `@qvac/llm-llamacpp/prebuilds/<host>/`.
|
|
* @returns {string}
|
|
*/
|
|
export function bareOsQvacNativeHostId() {
|
|
const p = globalThis.process || {}
|
|
const platform = String(p.platform || 'linux')
|
|
let arch = String(p.arch || 'x64')
|
|
if (arch === 'x86_64') arch = 'x64'
|
|
if (arch === 'aarch64') arch = 'arm64'
|
|
return platform + '-' + arch
|
|
}
|
|
|
|
/**
|
|
* Package root for `@qvac/llm-llamacpp` (may be `/app.bundle/...` in standalone).
|
|
* @returns {string}
|
|
*/
|
|
export function bareOsQvacLlmPackageRoot() {
|
|
try {
|
|
return barePath.dirname(requireFromBind.resolve('@qvac/llm-llamacpp'))
|
|
} catch {
|
|
try {
|
|
return barePath.dirname(
|
|
requireFromBind.resolve('@qvac/llm-llamacpp/package.json')
|
|
)
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Materialize ggml backend .so files to a real host path.
|
|
* Bare standalone embeds them under `/app.bundle/...` which dlopen cannot open;
|
|
* llama.cpp then fails with `make_cpu_buft_list: no CPU backend found`.
|
|
*
|
|
* Returns the prebuilds-equivalent root to pass as `backendsDir`
|
|
* (native appends `<host>/qvac__llm-llamacpp`).
|
|
* @returns {string}
|
|
*/
|
|
export function bareOsQvacEnsureHostBackendsDir() {
|
|
const host = bareOsQvacNativeHostId()
|
|
const slot = barePath.join(host, 'qvac__llm-llamacpp')
|
|
|
|
const envOverride = String(
|
|
globalThis.process?.env?.BARE_OS_QVAC_BACKENDS_DIR || ''
|
|
).trim()
|
|
if (envOverride) {
|
|
const hit = acceptBackendsRoot(envOverride, slot)
|
|
if (hit) return hit
|
|
}
|
|
|
|
// Already materialized under ~/.bare-os/qvac/backends
|
|
{
|
|
const hit = acceptBackendsRoot(bareOsQvacHostBackendsRoot(), slot)
|
|
if (hit) return hit
|
|
}
|
|
|
|
// Sidecar next to the standalone binary (pack + install.sh).
|
|
const sidecarRoots = []
|
|
for (const dir of bundledExecCandidates()) {
|
|
sidecarRoots.push(barePath.join(dir, 'qvac-backends'))
|
|
}
|
|
try {
|
|
const home = String(
|
|
globalThis.process?.env?.HOME ||
|
|
globalThis.process?.env?.USERPROFILE ||
|
|
''
|
|
)
|
|
if (home) {
|
|
sidecarRoots.push(
|
|
barePath.join(home, '.local', 'share', 'bare-os', 'booter', 'qvac-backends')
|
|
)
|
|
sidecarRoots.push(
|
|
barePath.join(home, '.bare-os', 'qvac', 'backends')
|
|
)
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
for (const beside of sidecarRoots) {
|
|
const hit = acceptBackendsRoot(beside, slot)
|
|
if (hit) return hit
|
|
}
|
|
|
|
// Bare require.asset() → real host path (or readable bytes).
|
|
{
|
|
const hit = materializeBackendsViaAsset(host, slot)
|
|
if (hit) return hit
|
|
}
|
|
|
|
const pkgRoot = bareOsQvacLlmPackageRoot()
|
|
if (!pkgRoot) return ''
|
|
const srcDir = barePath.join(pkgRoot, 'prebuilds', slot)
|
|
const srcPrebuilds = barePath.join(pkgRoot, 'prebuilds')
|
|
const bundleLike = /(?:^|\/)app\.bundle(?:\/|$)/i.test(srcDir)
|
|
|
|
// Dev / unpacked: use package prebuilds directly when they exist on disk.
|
|
if (!bundleLike) {
|
|
const hit = acceptBackendsRoot(srcPrebuilds, slot)
|
|
if (hit) return hit
|
|
}
|
|
|
|
// Copy from package/bundle path into host cache when readable.
|
|
return materializeBackendsToHost(srcDir, slot)
|
|
}
|
|
|
|
/**
|
|
* Absolute backendsDir for llama.cpp (real host path after materialize).
|
|
* @returns {string}
|
|
*/
|
|
export function bareOsQvacResolveBackendsDir() {
|
|
return bareOsQvacEnsureHostBackendsDir()
|
|
}
|
|
|
|
/**
|
|
* llama.cpp `--fit` trial-loads often fail on hybrid Vulkan (and then CPU
|
|
* inherits the same opaque error). SDK schema rejects `fit`, so inject into
|
|
* the native config map after transform via the addon constructor path.
|
|
*/
|
|
export function bareOsQvacPatchLlmLoadDefaults() {
|
|
if (llmLoadPatched || qvacBindIsStub) return
|
|
const proto = LlmLlamacpp && LlmLlamacpp.prototype
|
|
if (!proto || typeof proto._load !== 'function') return
|
|
llmLoadPatched = true
|
|
const orig = proto._load
|
|
proto._load = async function bareOsQvacPatchedLlmLoad() {
|
|
const prev =
|
|
this._config && typeof this._config === 'object' ? this._config : {}
|
|
/** @type {Record<string, string>} */
|
|
const next = { ...prev }
|
|
// This fabric build rejects --no-mmap; only inject fit (not in SDK schema).
|
|
const fitEnv = String(
|
|
globalThis.process?.env?.BARE_OS_QVAC_FIT ?? 'off'
|
|
)
|
|
.trim()
|
|
.toLowerCase()
|
|
if (next.fit == null || next.fit === '') {
|
|
next.fit = fitEnv === 'on' ? 'on' : 'off'
|
|
}
|
|
delete next.no_mmap
|
|
delete next['no-mmap']
|
|
// Re-resolve every load — first patch-time resolve can miss a late sidecar.
|
|
const backendsDir = bareOsQvacResolveBackendsDir()
|
|
if (backendsDir) {
|
|
next.backendsDir = backendsDir
|
|
} else if (next.backendsDir == null || next.backendsDir === '') {
|
|
// Avoid silent /app.bundle fallback: leave unset and let native fail loudly,
|
|
// but surface a clear JS warning once.
|
|
try {
|
|
const g = /** @type {any} */ (globalThis)
|
|
if (!g.__bareOsQvacBackendsDirWarned) {
|
|
g.__bareOsQvacBackendsDirWarned = true
|
|
console.warn(
|
|
'[qvac] ggml backendsDir unresolved — deploy qvac-backends/ beside bare-os-booter, or set BARE_OS_QVAC_BACKENDS_DIR (else: no CPU backend found)'
|
|
)
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
this._config = next
|
|
return orig.call(this)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register llamacpp completion and return bound SDK surface (registry + load/completion).
|
|
* Writes Bare-OS host cache config before plugins() so downloads land under
|
|
* `$BARE_OS_HOST_DATA/qvac/models` instead of `~/.qvac/models`.
|
|
* @param {{ cacheDir?: string }} [opts]
|
|
* @returns {Record<string, any>}
|
|
*/
|
|
export function createBoundQvacApi(opts = {}) {
|
|
if (qvacBindIsStub) {
|
|
throw new Error(
|
|
'QVAC natives not packed for this host (use agent --config REST, or rebuild without --skip-qvac on a supported host)'
|
|
)
|
|
}
|
|
const prepared = bareOsQvacPrepareHostCacheConfig({
|
|
cacheDir: opts.cacheDir
|
|
})
|
|
bareOsQvacPatchLlmLoadDefaults()
|
|
const pluginsFn = sdk.plugins
|
|
if (typeof pluginsFn !== 'function') {
|
|
throw new Error('@qvac/sdk plugins() unavailable')
|
|
}
|
|
const bound = pluginsFn([llmPlugin])
|
|
return {
|
|
...sdk,
|
|
loadModel: bound.loadModel || sdk.loadModel,
|
|
completion: bound.completion || sdk.completion,
|
|
unloadModel: bound.unloadModel || sdk.unloadModel,
|
|
cancel: bound.cancel || sdk.cancel,
|
|
__bareOsQvacCacheDir: prepared.cacheDir,
|
|
__bareOsQvacConfigPath: prepared.configPath,
|
|
__bareOsQvacBackendsDir: bareOsQvacResolveBackendsDir()
|
|
}
|
|
}
|