const crypto = require('crypto'); const fs = require('fs/promises'); const path = require('path'); const MODEL_DIR = path.resolve(__dirname, '..', '..', 'data', 'models', 'laya'); const MAX_BATCH_ITEMS = 64; const MAX_BATCH_BYTES = 64 * 1024; const MAX_TEXT_LENGTH = 4096; const ALLOWED_MODELS = new Set(['english', 'multilingual', 'typed-decisions']); class LayaRequestError extends Error { constructor(message, statusCode = 503) { super(message); this.name = 'LayaRequestError'; this.statusCode = statusCode; } } function validateBatchPayload(payload) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new LayaRequestError('Request body must be an object', 400); } if (!Array.isArray(payload.items) || payload.items.length === 0) { throw new LayaRequestError('items must be a non-empty array', 400); } if (Object.keys(payload).some(key => !['items', 'model'].includes(key))) { throw new LayaRequestError('Request body contains unsupported fields', 400); } if (payload.items.length > MAX_BATCH_ITEMS || Buffer.byteLength(JSON.stringify(payload)) > MAX_BATCH_BYTES) { throw new LayaRequestError('Batch payload is too large', 413); } payload.items.forEach((item, index) => { if (!item || typeof item !== 'object' || typeof item.text !== 'string' || !item.text.trim()) { throw new LayaRequestError(`items[${index}].text must be a non-empty string`, 400); } if (item.text.length > MAX_TEXT_LENGTH) { throw new LayaRequestError(`items[${index}].text is too long`, 413); } if (Object.keys(item).some(key => !['id', 'text'].includes(key))) { throw new LayaRequestError(`items[${index}] contains unsupported fields`, 400); } if (item.id !== undefined && !['string', 'number'].includes(typeof item.id)) { throw new LayaRequestError(`items[${index}].id must be a string or number`, 400); } }); if (payload.model !== undefined && !ALLOWED_MODELS.has(payload.model)) { throw new LayaRequestError('Unsupported Laya model', 400); } return payload; } async function clearDirectoryContents(directory) { const root = path.resolve(directory); await fs.mkdir(root, { recursive: true }); const entries = await fs.readdir(root); for (const entry of entries) { const target = path.resolve(root, entry); if (path.dirname(target) !== root) { throw new Error('Refusing to delete outside the Laya model directory'); } await fs.rm(target, { recursive: true, force: true }); } } class LayaService { constructor(options = {}) { this.baseUrl = options.baseUrl || process.env.LAYA_BASE_URL || 'http://laya:8000'; this.modelDir = path.resolve(options.modelDir || MODEL_DIR); this.fetch = options.fetch || globalThis.fetch; this.cache = options.cache === undefined ? require('../db/sqlite').layaLanguageCache : options.cache; this.timeoutMs = options.timeoutMs || 3000; } async request(endpoint, options = {}) { if (typeof this.fetch !== 'function') throw new LayaRequestError('Laya runtime client is unavailable'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); const headers = { 'content-type': 'application/json' }; if (process.env.LAYA_API_KEY) headers.authorization = `Bearer ${process.env.LAYA_API_KEY}`; try { const response = await this.fetch(`${this.baseUrl}${endpoint}`, { ...options, headers: { ...headers, ...options.headers }, signal: controller.signal }); const data = await response.json().catch(() => null); if (!response.ok) throw new LayaRequestError(`Laya runtime returned HTTP ${response.status}`, 502); return data; } catch (error) { if (error instanceof LayaRequestError) throw error; throw new LayaRequestError('Laya runtime is unavailable'); } finally { clearTimeout(timeout); } } async status() { await fs.mkdir(this.modelDir, { recursive: true }); const modelPresent = (await fs.readdir(this.modelDir)).length > 0; try { const runtime = await this.request('/health', { method: 'GET' }); return { runtimeAvailable: true, modelPresent, runtime }; } catch (error) { return { runtimeAvailable: false, modelPresent, error: error.message }; } } async downloadModel(model = 'multilingual') { if (!ALLOWED_MODELS.has(model)) throw new LayaRequestError('Unsupported Laya model', 400); await fs.mkdir(this.modelDir, { recursive: true }); await this.request('/v1/systemone', { method: 'POST', body: JSON.stringify({ state: 'Bonjour', model, questions: { language: { type: 'choice', instructions: 'Identify the language of this text.', criteria: { fr: 'French', en: 'English', es: 'Spanish', other: 'Another language' } } } }) }); return this.status(); } async deleteModel() { await clearDirectoryContents(this.modelDir); if (this.cache) this.cache.clear(); return { deleted: true, modelPresent: false }; } cacheKey(text, mode, model) { return crypto.createHash('sha256').update(`${mode}\0${model}\0${text}`).digest('hex'); } async detectBatch(payload) { validateBatchPayload(payload); const model = payload.model || 'multilingual'; const mode = 'laya'; const results = new Array(payload.items.length); const misses = []; payload.items.forEach((item, index) => { const key = this.cacheKey(item.text, mode, model); const cached = this.cache ? this.cache.get(key) : null; if (cached) results[index] = { ...cached, cached: true }; else misses.push({ item, index, key }); }); if (misses.length) { const response = await this.request('/v1/systemone/batch', { method: 'POST', body: JSON.stringify({ states: misses.map(({ item }) => item.text), model, questions: { language: { type: 'choice', instructions: 'Identify the language of this text.', criteria: { fr: 'French', en: 'English', es: 'Spanish', other: 'Another language' } } } }) }); if (!response || !Array.isArray(response.results) || response.results.length !== misses.length) { throw new LayaRequestError('Laya runtime returned an invalid batch response', 502); } misses.forEach(({ item, index, key }, responseIndex) => { const answer = response.results[responseIndex]?.answers?.language; const result = { id: item.id ?? index, language: answer?.choice || 'unknown', confidence: answer?.answer_confidence ?? answer?.confidence ?? 0, cached: false }; results[index] = result; if (this.cache) this.cache.set(key, mode, model, { ...result, cached: undefined }); }); } return { runtimeAvailable: true, model, results }; } } module.exports = { ALLOWED_MODELS, LayaRequestError, LayaService, MODEL_DIR, clearDirectoryContents, validateBatchPayload };