OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / api-ai-models.test.js

312 lines · javascript ref:
import test from 'brittle'
import {
  buildManageAIModelRegistration,
  buildManageAIModelsPayload,
  runAIModelManagementRouteAction,
  runManageAIModelRegisterAction,
  runManageAIModelRemoveAction,
  runManageAIModelsListAction,
  manageAIModelStatus,
  publicManageAIModelError,
  resolveAIModelManagementRoute
} from 'p2p-hiverelay/core/relay-node/api-ai-models.js'

test('api ai models: route helper maps exact POST model mutation routes', (t) => {
  t.alike(resolveAIModelManagementRoute('POST', '/api/manage/ai/models'), { kind: 'register' })
  t.alike(resolveAIModelManagementRoute('POST', '/api/manage/ai/models/remove'), { kind: 'remove' })

  t.is(resolveAIModelManagementRoute('GET', '/api/manage/ai/models'), null)
  t.is(resolveAIModelManagementRoute('POST', '/api/manage/ai/models/remove/extra'), null)
  t.is(resolveAIModelManagementRoute('POST', '/api/manage/ai/model'), null)
})

test('api ai models: route action helper dispatches register and remove primitives', async (t) => {
  const calls = []
  const provider = {
    async 'register-model' (params, ctx) {
      calls.push({ action: 'register', params, ctx })
      return { registered: true }
    },
    async 'remove-model' (params, ctx) {
      calls.push({ action: 'remove', params, ctx })
      return { removed: true }
    },
    async 'list-models' () {
      return [{ modelId: 'local-qvac', backend: 'qvac', qvac: { loaded: true } }]
    },
    async status () {
      return { qvac: { enabled: true, checked: true, available: true } }
    }
  }
  const node = { id: 'relay-node' }

  const registered = await runAIModelManagementRouteAction({
    route: { kind: 'register' },
    provider,
    body: { modelId: ' local-qvac ', qvac: { qvacModelId: 'loaded' } },
    node
  })
  const removed = await runAIModelManagementRouteAction({
    route: { kind: 'remove' },
    provider,
    body: { modelId: ' local-qvac ' },
    node
  })
  const unknown = await runAIModelManagementRouteAction({
    route: { kind: 'unknown' },
    provider,
    body: {}
  })

  t.is(registered.ok, true)
  t.is(registered.payload.action, 'registered')
  t.is(removed.ok, true)
  t.is(removed.payload.action, 'removed')
  t.alike(calls.map(call => call.action), ['register', 'remove'])
  t.is(calls[0].params.modelId, 'local-qvac')
  t.alike(calls[0].ctx, {
    role: 'relay-admin',
    caller: 'manage-api',
    authenticated: true,
    node
  })
  t.is(calls[1].params.modelId, 'local-qvac')
  t.is(unknown.status, 404)
  t.is(unknown.payload.error, 'unknown AI model management route')
})

test('api ai models: registration requires a model id and qvac source or loaded id', (t) => {
  t.alike(buildManageAIModelRegistration(null), { ok: false, error: 'request body required' })
  t.alike(buildManageAIModelRegistration([]), { ok: false, error: 'request body required' })
  t.alike(buildManageAIModelRegistration({}), { ok: false, error: 'modelId required' })
  t.alike(buildManageAIModelRegistration({ modelId: 'x', backend: 'http', endpoint: 'http://127.0.0.1' }), {
    ok: false,
    error: 'this endpoint only registers qvac-backed models'
  })
  t.alike(buildManageAIModelRegistration({ modelId: 'x' }), {
    ok: false,
    error: 'modelSrc or loadedModelId required for qvac model registration'
  })
})

test('api ai models: registration rejects malformed qvac option shapes', (t) => {
  const cases = [
    [{ modelId: 'x', qvac: [] }, 'qvac must be an object'],
    [{ modelId: 'x', modelSrc: '/models/a.gguf', modelConfig: [] }, 'modelConfig must be an object'],
    [{ modelId: 'x', qvac: { qvacModelId: 'loaded', loadOptions: [] } }, 'loadOptions must be an object'],
    [{ modelId: 'x', qvac: { qvacModelId: 'loaded', completionOptions: 'fast' } }, 'completionOptions must be an object'],
    [{ modelId: 'x', qvac: { qvacModelId: 'loaded', embedOptions: 3 } }, 'embedOptions must be an object'],
    [{ modelId: 'x', modelSrc: { path: '/models/a.gguf' } }, 'modelSrc must be a string'],
    [{ modelId: 'x', loadedModelId: 42 }, 'loadedModelId must be a string']
  ]

  for (const [body, error] of cases) {
    t.alike(buildManageAIModelRegistration(body), { ok: false, error })
  }
})

test('api ai models: registration normalizes top-level qvac model sources', (t) => {
  const out = buildManageAIModelRegistration({
    modelId: ' operator-qvac ',
    type: ' embeddings ',
    modelSrc: '/models/operator.gguf',
    modelType: 'llm',
    modelConfig: { ctx_size: 512 },
    delegate: 'worker-1',
    handler: () => {}
  })

  t.is(out.ok, true)
  t.absent(out.params.handler)
  t.is(out.params.modelId, 'operator-qvac')
  t.is(out.params.type, 'embeddings')
  t.is(out.params.backend, 'qvac')
  t.alike(out.params.qvac, {
    modelSrc: '/models/operator.gguf',
    loadedModelId: undefined,
    modelType: 'llm',
    modelConfig: { ctx_size: 512 },
    delegate: 'worker-1'
  })
})

test('api ai models: registration accepts nested loaded model aliases', (t) => {
  const out = buildManageAIModelRegistration({
    modelId: 'loaded',
    qvac: {
      qvacModelId: 'already-loaded',
      modelType: 'embed',
      modelConfig: { dimensions: 384 }
    }
  })

  t.is(out.ok, true)
  t.is(out.params.qvac.loadedModelId, 'already-loaded')
  t.is(out.params.qvac.modelType, 'embed')
  t.alike(out.params.qvac.modelConfig, { dimensions: 384 })
})

test('api ai models: status decoration distinguishes qvac and handler backends', (t) => {
  t.is(manageAIModelStatus({ backend: 'qvac', qvac: { loaded: true } }, { enabled: true, checked: true, available: true }), 'loaded')
  t.is(manageAIModelStatus({ backend: 'qvac', qvac: { loaded: false } }, { enabled: false }), 'disabled')
  t.is(manageAIModelStatus({ backend: 'qvac' }, { checked: true, available: false }), 'sdk-unavailable')
  t.is(manageAIModelStatus({ backend: 'qvac' }, { enabled: true, checked: true, available: true }), 'registered')
  t.is(manageAIModelStatus({ hasHandler: true }, null), 'handler-ready')
  t.is(manageAIModelStatus({ hasEndpoint: true }, null), 'http-ready')
  t.is(manageAIModelStatus({}, null), 'no-backend')
})

test('api ai models: payload decorates model list and counts qvac models', (t) => {
  const payload = buildManageAIModelsPayload([
    { modelId: 'a', backend: 'qvac', qvac: { loaded: true } },
    { modelId: 'b', hasEndpoint: true },
    { modelId: 'c', qvac: { loaded: false } }
  ], { qvac: { enabled: true, checked: true, available: true } })

  t.is(payload.ok, true)
  t.alike(payload.qvac, { enabled: true, checked: true, available: true })
  t.is(payload.count, 3)
  t.is(payload.qvacCount, 2)
  t.alike(payload.models.map(model => model.status), ['loaded', 'http-ready', 'registered'])
  t.alike(buildManageAIModelsPayload(null, null), {
    ok: true,
    qvac: null,
    count: 0,
    qvacCount: 0,
    models: []
  })
})

test('api ai models: public errors preserve stable AI codes and redact internals', (t) => {
  t.is(
    publicManageAIModelError(new Error('AI_MODEL_NOT_FOUND: local-qvac')),
    'AI_MODEL_NOT_FOUND: local-qvac'
  )
  t.is(
    publicManageAIModelError(new Error('ACCESS_DENIED: model removal requires relay-admin/local context')),
    'ACCESS_DENIED: model removal requires relay-admin/local context'
  )
  t.is(
    publicManageAIModelError(new Error('failed to read /data/models/private/key-material'), 'AI model registration failed'),
    'AI model registration failed'
  )
})

test('api ai models: list action decorates provider models and emits redacted failures', async (t) => {
  const contexts = []
  const provider = {
    async 'list-models' (params, ctx) {
      contexts.push(ctx)
      return [{ modelId: 'local-qvac', backend: 'qvac' }]
    },
    async status (params, ctx) {
      contexts.push(ctx)
      return { qvac: { enabled: true, checked: true, available: true } }
    }
  }

  const out = await runManageAIModelsListAction({ provider })
  t.is(out.ok, true)
  t.is(out.payload.count, 1)
  t.alike(contexts, [
    { role: 'relay-admin', caller: 'manage-api', authenticated: true },
    { role: 'relay-admin', caller: 'manage-api', authenticated: true }
  ])

  const events = []
  const failed = await runManageAIModelsListAction({
    provider: {
      async 'list-models' () { throw new Error('/private/models/key') }
    },
    emit: (name, event) => events.push({ name, event })
  })

  t.is(failed.ok, false)
  t.is(failed.status, 500)
  t.alike(failed.payload, { error: 'AI model list failed' })
  t.is(events[0].name, 'ai-model-error')
  t.is(events[0].event.action, 'list')
})

test('api ai models: register action validates, calls provider with node context, and returns decorated model', async (t) => {
  const node = { id: 'relay-node' }
  const calls = []
  const provider = {
    async 'register-model' (params, ctx) {
      calls.push({ params, ctx })
      return { registered: true }
    },
    async 'list-models' () {
      return [{ modelId: 'local-qvac', backend: 'qvac', qvac: { loaded: true } }]
    },
    async status () {
      return { qvac: { enabled: true, checked: true, available: true } }
    }
  }

  const invalid = await runManageAIModelRegisterAction({ provider, body: {} })
  t.alike(invalid, {
    ok: false,
    status: 400,
    payload: { error: 'modelId required' }
  })

  const out = await runManageAIModelRegisterAction({
    provider,
    body: { modelId: ' local-qvac ', qvac: { qvacModelId: 'loaded' } },
    node
  })

  t.is(out.ok, true)
  t.is(out.payload.action, 'registered')
  t.is(out.payload.model.modelId, 'local-qvac')
  t.is(out.payload.model.status, 'loaded')
  t.is(calls[0].params.modelId, 'local-qvac')
  t.alike(calls[0].ctx, {
    role: 'relay-admin',
    caller: 'manage-api',
    authenticated: true,
    node
  })
})

test('api ai models: register and remove actions preserve known errors and redact unexpected errors', async (t) => {
  const events = []
  const denied = await runManageAIModelRegisterAction({
    provider: {
      async 'register-model' () { throw new Error('ACCESS_DENIED: no') }
    },
    body: { modelId: 'local-qvac', qvac: { qvacModelId: 'loaded' } },
    emit: (name, event) => events.push({ name, event })
  })
  t.is(denied.status, 403)
  t.alike(denied.payload, { error: 'ACCESS_DENIED: no' })
  t.is(events[0].event.publicError, 'ACCESS_DENIED: no')

  const removed = await runManageAIModelRemoveAction({
    provider: {
      async 'remove-model' (params, ctx) {
        t.alike(params, { modelId: 'local-qvac' })
        t.is(ctx.node.id, 'relay-node')
        return { removed: true }
      },
      async 'list-models' () { return [] },
      async status () { return { qvac: { enabled: true } } }
    },
    body: { modelId: ' local-qvac ' },
    node: { id: 'relay-node' }
  })
  t.is(removed.ok, true)
  t.is(removed.payload.action, 'removed')
  t.is(removed.payload.removed, true)

  const failed = await runManageAIModelRemoveAction({
    provider: {
      async 'remove-model' () { throw new Error('/private/model/path') }
    },
    body: { modelId: 'local-qvac' }
  })
  t.is(failed.status, 500)
  t.alike(failed.payload, { error: 'AI model removal failed' })
})