OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / cli / qvac.js

248 lines · javascript ref:
/**
 * HiveRelay CLI - `qvac` model management.
 *
 * Operator-facing wrappers around /api/manage/ai/models. This avoids
 * hand-written ai.register-model / ai.remove-model dispatch calls while
 * keeping the relay's management API auth boundary in place.
 */

import {
  resolveApiUrl, resolveApiKey, performRequest
} from './catalog.js'

const VALID_MODEL_TYPES = ['llm', 'embedding', 'classification', 'image', 'custom']

export const QVAC_HELP = `Usage:
  hiverelay qvac list
  hiverelay qvac register <modelId> --model-src <path-or-id> [--type <kind>]
  hiverelay qvac register <modelId> --loaded-model-id <id> [--type <kind>]
  hiverelay qvac remove <modelId>

Options:
  --api-url <url>              Relay API base URL (default: http://127.0.0.1:9100)
  --api-key <key>              API key (or use HIVERELAY_API_KEY env)
  --type <kind>                llm | embedding | classification | image | custom (default: llm)
  --model-src <path-or-id>     qvac model source passed to @qvac/sdk loadModel
  --loaded-model-id <id>       Existing qvac-loaded model id
  --model-type <kind>          qvac model type override
  --model-config <json>        JSON object passed as qvac modelConfig
  --load-options <json>        JSON object merged into qvac load options
  --completion-options <json>  JSON object merged into qvac completion options
  --embed-options <json>       JSON object merged into qvac embed options
  --delegate <id>              qvac delegate id
  --endpoint <url>             Optional HTTP fallback endpoint
  --no-fallback-http           Disable HTTP fallback when qvac is unavailable
`

function normalizeSubcommand (subcommand) {
  if (subcommand === 'ls') return 'list'
  if (subcommand === 'add') return 'register'
  if (subcommand === 'rm') return 'remove'
  return subcommand
}

function option (argv, kebab, camel) {
  if (argv[kebab] !== undefined) return argv[kebab]
  if (camel && argv[camel] !== undefined) return argv[camel]
  return undefined
}

function parseJsonObjectOption (argv, key, label) {
  const value = option(argv, key)
  if (value === undefined) return undefined
  let parsed
  try {
    parsed = typeof value === 'string' ? JSON.parse(value) : value
  } catch (err) {
    throw new Error(label + ' must be valid JSON: ' + err.message)
  }
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
    throw new Error(label + ' must be a JSON object')
  }
  return parsed
}

export function buildQvacRequest (subcommandRaw, positional, argv = {}) {
  const subcommand = normalizeSubcommand(subcommandRaw)

  switch (subcommand) {
    case 'list':
      return { method: 'GET', path: '/api/manage/ai/models', body: null }

    case 'register': {
      const modelId = positional[0]
      if (!modelId || typeof modelId !== 'string') {
        throw new Error('modelId required')
      }

      const type = String(option(argv, 'type') || positional[1] || 'llm')
      if (!VALID_MODEL_TYPES.includes(type)) {
        throw new Error('--type must be one of: ' + VALID_MODEL_TYPES.join(', '))
      }

      const modelSrc = option(argv, 'model-src', 'modelSrc')
      const loadedModelId = option(argv, 'loaded-model-id', 'loadedModelId') ||
        option(argv, 'qvac-model-id', 'qvacModelId')
      if (!modelSrc && !loadedModelId) {
        throw new Error('register requires --model-src or --loaded-model-id')
      }

      const body = {
        modelId,
        type,
        backend: 'qvac'
      }
      if (modelSrc !== undefined) body.modelSrc = String(modelSrc)
      if (loadedModelId !== undefined) body.loadedModelId = String(loadedModelId)

      const modelType = option(argv, 'model-type', 'modelType')
      if (modelType !== undefined) body.modelType = String(modelType)

      const delegate = option(argv, 'delegate')
      if (delegate !== undefined) body.delegate = String(delegate)

      const endpoint = option(argv, 'endpoint')
      if (endpoint !== undefined) body.endpoint = String(endpoint)

      const modelConfig = parseJsonObjectOption(argv, 'model-config', '--model-config')
      if (modelConfig !== undefined) body.modelConfig = modelConfig

      const qvac = {}
      const loadOptions = parseJsonObjectOption(argv, 'load-options', '--load-options')
      if (loadOptions !== undefined) qvac.loadOptions = loadOptions
      const completionOptions = parseJsonObjectOption(argv, 'completion-options', '--completion-options')
      if (completionOptions !== undefined) qvac.completionOptions = completionOptions
      const embedOptions = parseJsonObjectOption(argv, 'embed-options', '--embed-options')
      if (embedOptions !== undefined) qvac.embedOptions = embedOptions
      if (argv['fallback-http'] === false) qvac.fallbackToHttp = false
      if (Object.keys(qvac).length > 0) body.qvac = qvac

      return { method: 'POST', path: '/api/manage/ai/models', body }
    }

    case 'remove': {
      const modelId = positional[0]
      if (!modelId || typeof modelId !== 'string') {
        throw new Error('modelId required')
      }
      return {
        method: 'POST',
        path: '/api/manage/ai/models/remove',
        body: { modelId }
      }
    }

    default:
      throw new Error('Unknown qvac subcommand: ' + subcommandRaw)
  }
}

function sdkStatusLabel (qvac) {
  if (!qvac) return 'unknown'
  if (qvac.enabled === false) return 'disabled'
  if (qvac.checked && qvac.available === false) return 'unavailable'
  if (qvac.available === true) return 'available'
  return 'not checked'
}

function modelStatusLabel (model) {
  if (model.status) return model.status
  if (model.qvac?.loaded) return 'loaded'
  if (model.backend === 'qvac' || model.qvac) return 'registered'
  if (model.hasHandler) return 'handler-ready'
  if (model.hasEndpoint) return 'http-ready'
  return 'unknown'
}

export function formatQvacModelList (payload) {
  const qvac = payload?.qvac || null
  const models = Array.isArray(payload?.models)
    ? payload.models.filter(model => model.backend === 'qvac' || model.qvac)
    : []
  const lines = []
  const moduleName = qvac?.module ? ' module:' + qvac.module : ''
  const error = qvac?.error ? ' error:' + qvac.error : ''
  lines.push('QVAC SDK: ' + sdkStatusLabel(qvac) + moduleName + error)

  if (models.length === 0) {
    lines.push('No qvac models registered.')
    return lines.join('\n')
  }

  lines.push('QVAC models: ' + models.length)
  for (const model of models) {
    const stats = model.stats || {}
    const q = model.qvac || {}
    const loaded = q.loaded ? 'yes' : 'no'
    const delegated = q.delegated ? 'yes' : 'no'
    const src = q.hasModelSrc ? 'yes' : 'no'
    lines.push(
      '  ' + model.modelId +
      '  type:' + (model.type || 'unknown') +
      '  status:' + modelStatusLabel(model) +
      '  loaded:' + loaded +
      '  modelSrc:' + src +
      '  delegated:' + delegated +
      '  requests:' + (stats.requests || 0) +
      '  errors:' + (stats.errors || 0)
    )
  }
  return lines.join('\n')
}

export async function runQvacCommand ({
  argv,
  positional,
  env = process.env,
  fetchImpl,
  out = (msg) => console.log(msg),
  err = (msg) => console.error(msg)
}) {
  const subcommandRaw = positional[0]
  if (!subcommandRaw || subcommandRaw === 'help' || argv.help) {
    out(QVAC_HELP)
    return 0
  }
  const subcommand = normalizeSubcommand(subcommandRaw)

  let apiUrl
  try {
    apiUrl = resolveApiUrl(argv)
  } catch (e) {
    err(e.message)
    return 1
  }
  const apiKey = resolveApiKey(argv, env)

  let request
  try {
    request = buildQvacRequest(subcommand, positional.slice(1), argv)
  } catch (e) {
    err(e.message)
    return 1
  }

  let payload
  try {
    payload = await performRequest({
      apiUrl, apiKey, method: request.method, path: request.path, body: request.body, fetchImpl
    })
  } catch (e) {
    err('qvac ' + subcommand + ' failed: ' + e.message)
    return 1
  }

  if (subcommand === 'list') {
    out(formatQvacModelList(payload))
    return 0
  }

  if (subcommand === 'register') {
    const status = payload?.model ? modelStatusLabel(payload.model) : 'registered'
    out('OK - qvac model registered: ' + request.body.modelId + ' status:' + status)
  } else if (subcommand === 'remove') {
    out('OK - qvac model removed: ' + request.body.modelId + (payload?.removed ? '' : ' (no-op)'))
  }
  return 0
}