344 lines
9.4 KiB
JavaScript
344 lines
9.4 KiB
JavaScript
const express = require('express');
|
|
const wrapAsync = require('../helpers').wrapAsync;
|
|
const { LocalAIApi } = require('../ai/LocalAIApi');
|
|
|
|
const router = express.Router();
|
|
|
|
const DIAGNOSTICS_CACHE_TTL_MS = 2 * 60 * 1000;
|
|
const DEFAULT_PROBE_PROMPT = 'Reply with OK only.';
|
|
|
|
let diagnosticsCache = {
|
|
expiresAt: 0,
|
|
payload: null,
|
|
};
|
|
|
|
const RELEVANT_ENV_KEYS = [
|
|
'AI_PROXY_BASE_URL',
|
|
'AI_RESPONSES_PATH',
|
|
'AI_DEFAULT_MODEL',
|
|
'AI_PROJECT_HEADER',
|
|
'AI_TIMEOUT',
|
|
'AI_VERIFY_TLS',
|
|
'PROJECT_ID',
|
|
'PROJECT_UUID',
|
|
'OPENAI_API_KEY',
|
|
'GEMINI_API_KEY',
|
|
'GEMINI_MODEL',
|
|
'GOOGLE_API_KEY',
|
|
'GOOGLE_CLOUD_PROJECT',
|
|
'GOOGLE_GENAI_USE_VERTEXAI',
|
|
'OPENCODE_MODEL',
|
|
];
|
|
|
|
function shellQuote(value) {
|
|
return `'${String(value ?? '').replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function uniqueStrings(values) {
|
|
return [...new Set((values || [])
|
|
.map((value) => String(value || '').trim())
|
|
.filter(Boolean))];
|
|
}
|
|
|
|
function getEnvironmentEntries() {
|
|
return Object.keys(process.env)
|
|
.sort((left, right) => left.localeCompare(right))
|
|
.map((key) => ({
|
|
key,
|
|
value: String(process.env[key] ?? ''),
|
|
}));
|
|
}
|
|
|
|
function getRelevantEnvironmentEntries() {
|
|
const availableKeys = uniqueStrings([
|
|
...RELEVANT_ENV_KEYS,
|
|
...Object.keys(process.env).filter((key) => /(AI|LLM|MODEL|OPENAI|GEMINI|GOOGLE|PROJECT|API)/i.test(key)),
|
|
]);
|
|
|
|
return availableKeys
|
|
.filter((key) => Object.prototype.hasOwnProperty.call(process.env, key))
|
|
.sort((left, right) => left.localeCompare(right))
|
|
.map((key) => ({
|
|
key,
|
|
value: String(process.env[key] ?? ''),
|
|
}));
|
|
}
|
|
|
|
function buildAbsoluteUrl(baseUrl, pathValue) {
|
|
const trimmedBase = String(baseUrl || '').replace(/\/+$/, '');
|
|
const trimmedPath = String(pathValue || '').trim();
|
|
|
|
if (!trimmedPath) {
|
|
return trimmedBase;
|
|
}
|
|
|
|
if (/^https?:\/\//i.test(trimmedPath)) {
|
|
return trimmedPath;
|
|
}
|
|
|
|
if (trimmedPath.startsWith('/')) {
|
|
return `${trimmedBase}${trimmedPath}`;
|
|
}
|
|
|
|
return `${trimmedBase}/${trimmedPath}`;
|
|
}
|
|
|
|
function resolveProxyConfig() {
|
|
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 defaultModel = process.env.AI_DEFAULT_MODEL || 'gpt-5-mini';
|
|
const projectHeader = process.env.AI_PROJECT_HEADER || 'project-uuid';
|
|
const projectUuid = process.env.PROJECT_UUID || null;
|
|
|
|
return {
|
|
baseUrl,
|
|
responsesPath,
|
|
resolvedUrl: buildAbsoluteUrl(baseUrl, responsesPath),
|
|
defaultModel,
|
|
projectHeader,
|
|
projectUuid,
|
|
projectId,
|
|
timeoutSeconds: Number.parseInt(String(process.env.AI_TIMEOUT || 30), 10) || 30,
|
|
verifyTls: !['false', '0'].includes(String(process.env.AI_VERIFY_TLS ?? 'true').toLowerCase()),
|
|
};
|
|
}
|
|
|
|
function buildRequestOrigin(req) {
|
|
return `${req.protocol}://${req.get('host')}`;
|
|
}
|
|
|
|
function buildProbeCandidates(proxyConfig) {
|
|
return uniqueStrings([
|
|
proxyConfig.defaultModel,
|
|
'gpt-5-mini',
|
|
'gpt-4.1-mini',
|
|
'gpt-4o-mini',
|
|
process.env.GEMINI_MODEL,
|
|
process.env.OPENCODE_MODEL,
|
|
]);
|
|
}
|
|
|
|
async function probeModel(model) {
|
|
const startedAt = Date.now();
|
|
|
|
try {
|
|
const response = await LocalAIApi.createResponse(
|
|
{
|
|
model,
|
|
input: [{ role: 'user', content: DEFAULT_PROBE_PROMPT }],
|
|
},
|
|
{
|
|
poll_interval: 2,
|
|
poll_timeout: 25,
|
|
},
|
|
);
|
|
|
|
if (response.success) {
|
|
return {
|
|
model,
|
|
success: true,
|
|
latencyMs: Date.now() - startedAt,
|
|
previewText: LocalAIApi.extractText(response) || null,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
model,
|
|
success: false,
|
|
latencyMs: Date.now() - startedAt,
|
|
previewText: null,
|
|
error: response.error || response.message || 'unknown_error',
|
|
};
|
|
} catch (error) {
|
|
console.error(`Model probe failed for ${model}:`, error);
|
|
return {
|
|
model,
|
|
success: false,
|
|
latencyMs: Date.now() - startedAt,
|
|
previewText: null,
|
|
error: error.message || String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
function buildCurlPayload(model, prompt) {
|
|
return {
|
|
model,
|
|
input: [{ role: 'user', content: prompt }],
|
|
};
|
|
}
|
|
|
|
function joinCurlLines(lines) {
|
|
return lines
|
|
.filter(Boolean)
|
|
.map((line, index, allLines) => (index < allLines.length - 1 ? `${line} \\` : line))
|
|
.join('\n');
|
|
}
|
|
|
|
function buildAppProxyCurl(appProxyUrl, model, prompt) {
|
|
const payload = {
|
|
model,
|
|
prompt,
|
|
};
|
|
|
|
return joinCurlLines([
|
|
`curl -sS ${shellQuote(appProxyUrl)}`,
|
|
'-X POST',
|
|
"-H 'Content-Type: application/json'",
|
|
`--data ${shellQuote(JSON.stringify(payload))}`,
|
|
]);
|
|
}
|
|
|
|
function buildFlatlogicProxyCurl(proxyConfig, model, prompt) {
|
|
const payload = buildCurlPayload(model, prompt);
|
|
payload.project_uuid = proxyConfig.projectUuid;
|
|
const projectHeaderLine = shellQuote(`${proxyConfig.projectHeader}: ${proxyConfig.projectUuid || ''}`);
|
|
|
|
return joinCurlLines([
|
|
`curl -sS ${shellQuote(proxyConfig.resolvedUrl)}`,
|
|
'-X POST',
|
|
"-H 'Content-Type: application/json'",
|
|
`-H ${projectHeaderLine}`,
|
|
`--data ${shellQuote(JSON.stringify(payload))}`,
|
|
]);
|
|
}
|
|
|
|
function buildOpenAICurl(model, prompt) {
|
|
const apiKey = process.env.OPENAI_API_KEY || '';
|
|
const payload = buildCurlPayload(model, prompt);
|
|
const url = 'https://api.openai.com/v1/responses';
|
|
const authorizationHeader = shellQuote(`Authorization: Bearer ${apiKey}`);
|
|
|
|
return joinCurlLines([
|
|
`curl -sS ${shellQuote(url)}`,
|
|
'-X POST',
|
|
"-H 'Content-Type: application/json'",
|
|
`-H ${authorizationHeader}`,
|
|
`--data ${shellQuote(JSON.stringify(payload))}`,
|
|
]);
|
|
}
|
|
|
|
function buildGeminiCurl(prompt) {
|
|
const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '';
|
|
const configuredModel = String(process.env.GEMINI_MODEL || '').trim();
|
|
const model = configuredModel.startsWith('gemini-') ? configuredModel : 'gemini-2.5-flash';
|
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
|
const payload = {
|
|
contents: [
|
|
{
|
|
role: 'user',
|
|
parts: [{ text: prompt }],
|
|
},
|
|
],
|
|
};
|
|
|
|
return joinCurlLines([
|
|
`curl -sS ${shellQuote(url)}`,
|
|
'-X POST',
|
|
"-H 'Content-Type: application/json'",
|
|
`--data ${shellQuote(JSON.stringify(payload))}`,
|
|
]);
|
|
}
|
|
|
|
async function buildDiagnostics(req) {
|
|
const proxyConfig = resolveProxyConfig();
|
|
const requestOrigin = buildRequestOrigin(req);
|
|
const appProxyUrl = `${requestOrigin}/api/public-llm/proxy`;
|
|
const defaultPrompt = 'Reply with a concise health check for this LLM proxy.';
|
|
const probeCandidates = buildProbeCandidates(proxyConfig);
|
|
const probeResults = await Promise.all(probeCandidates.map((model) => probeModel(model)));
|
|
const availableModels = probeResults.filter((result) => result.success).map((result) => result.model);
|
|
const unavailableModels = probeResults.filter((result) => !result.success).map((result) => result.model);
|
|
const preferredModel = availableModels[0] || probeCandidates[0] || proxyConfig.defaultModel;
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
requestOrigin,
|
|
appProxyUrl,
|
|
defaultPrompt,
|
|
envVars: getEnvironmentEntries(),
|
|
llmEnvVars: getRelevantEnvironmentEntries(),
|
|
proxyConfig,
|
|
probeCandidates,
|
|
probeResults,
|
|
availableModels,
|
|
unavailableModels,
|
|
curlCommands: {
|
|
appProxy: buildAppProxyCurl(appProxyUrl, preferredModel, defaultPrompt),
|
|
flatlogicProxy: buildFlatlogicProxyCurl(proxyConfig, preferredModel, defaultPrompt),
|
|
openaiDirect: buildOpenAICurl(preferredModel, defaultPrompt),
|
|
geminiDirect: buildGeminiCurl(defaultPrompt),
|
|
},
|
|
};
|
|
}
|
|
|
|
router.get(
|
|
'/diagnostics',
|
|
wrapAsync(async (req, res) => {
|
|
const forceRefresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
|
|
|
if (!forceRefresh && diagnosticsCache.payload && diagnosticsCache.expiresAt > Date.now()) {
|
|
return res.status(200).send(diagnosticsCache.payload);
|
|
}
|
|
|
|
const payload = await buildDiagnostics(req);
|
|
diagnosticsCache = {
|
|
expiresAt: Date.now() + DIAGNOSTICS_CACHE_TTL_MS,
|
|
payload,
|
|
};
|
|
|
|
return res.status(200).send(payload);
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/proxy',
|
|
wrapAsync(async (req, res) => {
|
|
const prompt = typeof req.body.prompt === 'string' ? req.body.prompt.trim() : '';
|
|
const input = Array.isArray(req.body.input) ? req.body.input : null;
|
|
const model = typeof req.body.model === 'string' && req.body.model.trim()
|
|
? req.body.model.trim()
|
|
: resolveProxyConfig().defaultModel;
|
|
|
|
if (!prompt && !input) {
|
|
return res.status(400).send({
|
|
success: false,
|
|
error: 'input_missing',
|
|
message: 'Either "prompt" or "input" must be provided.',
|
|
});
|
|
}
|
|
|
|
const payload = {
|
|
model,
|
|
input: input || [{ role: 'user', content: prompt }],
|
|
};
|
|
|
|
const response = await LocalAIApi.createResponse(payload, {
|
|
poll_interval: 2,
|
|
poll_timeout: 60,
|
|
});
|
|
|
|
if (!response.success) {
|
|
console.error('Public LLM proxy error:', response);
|
|
const status = response.error === 'input_missing' ? 400 : 502;
|
|
return res.status(status).send(response);
|
|
}
|
|
|
|
return res.status(200).send({
|
|
success: true,
|
|
model,
|
|
text: LocalAIApi.extractText(response) || '',
|
|
response,
|
|
});
|
|
}),
|
|
);
|
|
|
|
module.exports = router;
|