39948-vm/backend/src/ai/LocalAIApi.js

513 lines
13 KiB
JavaScript

'use strict';
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const { URL } = require('url');
let CONFIG_CACHE = null;
class LocalAIApi {
static createResponse(params, options) {
return createResponse(params, options);
}
static request(pathValue, payload, options) {
return request(pathValue, payload, options);
}
static fetchStatus(aiRequestId, options) {
return fetchStatus(aiRequestId, options);
}
static awaitResponse(aiRequestId, options) {
return awaitResponse(aiRequestId, options);
}
static extractText(response) {
return extractText(response);
}
static decodeJsonFromResponse(response) {
return decodeJsonFromResponse(response);
}
}
async function createResponse(params, options = {}) {
const payload = { ...(params || {}) };
if (!Array.isArray(payload.input) || payload.input.length === 0) {
return {
success: false,
error: 'input_missing',
message: 'Parameter "input" is required and must be a non-empty array.',
};
}
const cfg = config();
if (!payload.model) {
payload.model = cfg.defaultModel;
}
const initial = await request(options.path, payload, options);
if (!initial.success) {
return initial;
}
const data = initial.data;
if (data && typeof data === 'object' && data.ai_request_id) {
const pollTimeout = Number(options.poll_timeout ?? 300);
const pollInterval = Number(options.poll_interval ?? 5);
return await awaitResponse(data.ai_request_id, {
interval: pollInterval,
timeout: pollTimeout,
headers: options.headers,
timeout_per_call: options.timeout,
verify_tls: options.verify_tls,
});
}
return initial;
}
async function request(pathValue, payload = {}, options = {}) {
const cfg = config();
const resolvedPath = pathValue || options.path || cfg.responsesPath;
if (!resolvedPath) {
return {
success: false,
error: 'project_id_missing',
message: 'PROJECT_ID is not defined; cannot resolve AI proxy endpoint.',
};
}
if (!cfg.projectUuid) {
return {
success: false,
error: 'project_uuid_missing',
message: 'PROJECT_UUID is not defined; aborting AI request.',
};
}
const bodyPayload = { ...(payload || {}) };
if (!bodyPayload.project_uuid) {
bodyPayload.project_uuid = cfg.projectUuid;
}
const url = buildUrl(resolvedPath, cfg.baseUrl);
const timeout = resolveTimeout(options.timeout, cfg.timeout);
const verifyTls = resolveVerifyTls(options.verify_tls, cfg.verifyTls);
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
[cfg.projectHeader]: cfg.projectUuid,
};
if (Array.isArray(options.headers)) {
for (const header of options.headers) {
if (typeof header === 'string' && header.includes(':')) {
const [name, value] = header.split(':', 2);
headers[name.trim()] = value.trim();
}
}
}
const body = JSON.stringify(bodyPayload);
return sendRequest(url, 'POST', body, headers, timeout, verifyTls);
}
async function fetchStatus(aiRequestId, options = {}) {
const cfg = config();
if (!cfg.projectUuid) {
return {
success: false,
error: 'project_uuid_missing',
message: 'PROJECT_UUID is not defined; aborting status check.',
};
}
const statusPath = resolveStatusPath(aiRequestId, cfg);
const url = buildUrl(statusPath, cfg.baseUrl);
const timeout = resolveTimeout(options.timeout, cfg.timeout);
const verifyTls = resolveVerifyTls(options.verify_tls, cfg.verifyTls);
const headers = {
Accept: 'application/json',
[cfg.projectHeader]: cfg.projectUuid,
};
if (Array.isArray(options.headers)) {
for (const header of options.headers) {
if (typeof header === 'string' && header.includes(':')) {
const [name, value] = header.split(':', 2);
headers[name.trim()] = value.trim();
}
}
}
return sendRequest(url, 'GET', null, headers, timeout, verifyTls);
}
async function awaitResponse(aiRequestId, options = {}) {
const timeout = Number(options.timeout ?? 300);
const interval = Math.max(Number(options.interval ?? 5), 1);
const deadline = Date.now() + Math.max(timeout, interval) * 1000;
let isPending = true;
while (isPending) {
const statusResp = await fetchStatus(aiRequestId, {
headers: options.headers,
timeout: options.timeout_per_call,
verify_tls: options.verify_tls,
});
if (statusResp.success) {
const data = statusResp.data || {};
if (data && typeof data === 'object') {
if (data.status === 'success') {
isPending = false;
return {
success: true,
status: 200,
data: data.response || data,
};
}
if (data.status === 'failed') {
isPending = false;
return {
success: false,
status: 500,
error: String(data.error || 'AI request failed'),
data,
};
}
}
} else {
return statusResp;
}
if (Date.now() >= deadline) {
return {
success: false,
error: 'timeout',
message: 'Timed out waiting for AI response.',
};
}
await sleep(interval * 1000);
}
}
function extractText(response) {
const payload =
response && typeof response === 'object' ? response.data || response : null;
if (!payload || typeof payload !== 'object') {
return '';
}
if (Array.isArray(payload.output)) {
let combined = '';
for (const item of payload.output) {
if (!item || !Array.isArray(item.content)) {
continue;
}
for (const block of item.content) {
if (
block &&
typeof block === 'object' &&
block.type === 'output_text' &&
typeof block.text === 'string' &&
block.text.length > 0
) {
combined += block.text;
}
}
}
if (combined) {
return combined;
}
}
if (
payload.choices &&
payload.choices[0] &&
payload.choices[0].message &&
typeof payload.choices[0].message.content === 'string'
) {
return payload.choices[0].message.content;
}
return '';
}
function decodeJsonFromResponse(response) {
const text = extractText(response);
if (!text) {
throw new Error('No text found in AI response.');
}
const parsed = parseJson(text);
if (parsed.ok && parsed.value && typeof parsed.value === 'object') {
return parsed.value;
}
const stripped = stripJsonFence(text);
if (stripped !== text) {
const parsedStripped = parseJson(stripped);
if (
parsedStripped.ok &&
parsedStripped.value &&
typeof parsedStripped.value === 'object'
) {
return parsedStripped.value;
}
throw new Error(
`JSON parse failed after stripping fences: ${parsedStripped.error}`,
);
}
throw new Error(`JSON parse failed: ${parsed.error}`);
}
function config() {
if (CONFIG_CACHE) {
return CONFIG_CACHE;
}
ensureEnvLoaded();
const baseUrl = process.env.AI_PROXY_BASE_URL || 'https://flatlogic.com';
const projectId = process.env.PROJECT_ID || null;
let responsesPath = process.env.AI_RESPONSES_PATH || null;
if (!responsesPath && projectId) {
responsesPath = `/projects/${projectId}/ai-request`;
}
const timeout = resolveTimeout(process.env.AI_TIMEOUT, 30);
const verifyTls = resolveVerifyTls(process.env.AI_VERIFY_TLS, true);
CONFIG_CACHE = {
baseUrl,
responsesPath,
projectId,
projectUuid: process.env.PROJECT_UUID || null,
projectHeader: process.env.AI_PROJECT_HEADER || 'project-uuid',
defaultModel: process.env.AI_DEFAULT_MODEL || 'gpt-5-mini',
timeout,
verifyTls,
};
return CONFIG_CACHE;
}
function buildUrl(pathValue, baseUrl) {
const trimmed = String(pathValue || '').trim();
if (trimmed === '') {
return baseUrl;
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
if (trimmed.startsWith('/')) {
return `${baseUrl}${trimmed}`;
}
return `${baseUrl}/${trimmed}`;
}
function resolveStatusPath(aiRequestId, cfg) {
const basePath = (cfg.responsesPath || '').replace(/\/+$/, '');
if (!basePath) {
return `/ai-request/${encodeURIComponent(String(aiRequestId))}/status`;
}
const normalized = basePath.endsWith('/ai-request')
? basePath
: `${basePath}/ai-request`;
return `${normalized}/${encodeURIComponent(String(aiRequestId))}/status`;
}
function sendRequest(
urlString,
method,
body,
headers,
timeoutSeconds,
verifyTls,
) {
return new Promise((resolve) => {
let targetUrl;
try {
targetUrl = new URL(urlString);
} catch (err) {
resolve({
success: false,
error: 'invalid_url',
message: err.message,
});
return;
}
const isHttps = targetUrl.protocol === 'https:';
const requestFn = isHttps ? https.request : http.request;
const options = {
protocol: targetUrl.protocol,
hostname: targetUrl.hostname,
port: targetUrl.port || (isHttps ? 443 : 80),
path: `${targetUrl.pathname}${targetUrl.search}`,
method: method.toUpperCase(),
headers,
timeout: Math.max(Number(timeoutSeconds || 30), 1) * 1000,
};
if (isHttps) {
options.rejectUnauthorized = Boolean(verifyTls);
}
const req = requestFn(options, (res) => {
let responseBody = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
responseBody += chunk;
});
res.on('end', () => {
const status = res.statusCode || 0;
const parsed = parseJson(responseBody);
const payload = parsed.ok ? parsed.value : responseBody;
if (status >= 200 && status < 300) {
const result = {
success: true,
status,
data: payload,
};
if (!parsed.ok) {
result.json_error = parsed.error;
}
resolve(result);
return;
}
const errorMessage =
parsed.ok && payload && typeof payload === 'object'
? String(
payload.error || payload.message || 'AI proxy request failed',
)
: String(responseBody || 'AI proxy request failed');
resolve({
success: false,
status,
error: errorMessage,
response: payload,
json_error: parsed.ok ? undefined : parsed.error,
});
});
});
req.on('timeout', () => {
req.destroy(new Error('request_timeout'));
});
req.on('error', (err) => {
resolve({
success: false,
error: 'request_failed',
message: err.message,
});
});
if (body) {
req.write(body);
}
req.end();
});
}
function parseJson(value) {
if (typeof value !== 'string' || value.trim() === '') {
return { ok: false, error: 'empty_response' };
}
try {
return { ok: true, value: JSON.parse(value) };
} catch (err) {
return { ok: false, error: err.message };
}
}
function stripJsonFence(text) {
const trimmed = text.trim();
if (trimmed.startsWith('```json')) {
return trimmed
.replace(/^```json/, '')
.replace(/```$/, '')
.trim();
}
if (trimmed.startsWith('```')) {
return trimmed.replace(/^```/, '').replace(/```$/, '').trim();
}
return text;
}
function resolveTimeout(value, fallback) {
const parsed = Number.parseInt(String(value ?? fallback), 10);
return Number.isNaN(parsed) ? Number(fallback) : parsed;
}
function resolveVerifyTls(value, fallback) {
if (value === undefined || value === null) {
return Boolean(fallback);
}
return String(value).toLowerCase() !== 'false' && String(value) !== '0';
}
function ensureEnvLoaded() {
if (process.env.PROJECT_UUID && process.env.PROJECT_ID) {
return;
}
const envPath = path.resolve(__dirname, '../../../../.env');
if (!fs.existsSync(envPath)) {
return;
}
let content;
try {
content = fs.readFileSync(envPath, 'utf8');
} catch (err) {
throw new Error(`Failed to read executor .env: ${err.message}`);
}
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) {
continue;
}
const [rawKey, ...rest] = trimmed.split('=');
const key = rawKey.trim();
if (!key) {
continue;
}
const value = rest
.join('=')
.trim()
.replace(/^['"]|['"]$/g, '');
if (!process.env[key]) {
process.env[key] = value;
}
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
module.exports = {
LocalAIApi,
createResponse,
request,
fetchStatus,
awaitResponse,
extractText,
decodeJsonFromResponse,
};