513 lines
13 KiB
JavaScript
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,
|
|
};
|