485 lines
12 KiB
JavaScript
485 lines
12 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;
|
|
|
|
while (true) {
|
|
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") {
|
|
return {
|
|
success: true,
|
|
status: 200,
|
|
data: data.response || data,
|
|
};
|
|
}
|
|
if (data.status === "failed") {
|
|
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,
|
|
};
|