Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 58s
Forgejo Android APK / Build signed APK (push) Successful in 2m9s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
npm audit reports 0 vulnerabilities. It reported 2 high and 2 moderate this
morning.
@google-cloud/vertexai was the last source of findings — gaxios and a uuid with
a missing buffer bounds check, neither reachable in this deployment because
GOOGLE_VERTEX_PROJECT is unset and the require sits inside that check. Dormant
is not the same as gone, and the provider is available through the gateway
anyway, so the direct path has been removed rather than left to rot:
- the SDK client and callVertex, which without the package could never run
- the dispatch and discovery branches that reached them
- VERTEX_MODELS, a list of ids nothing could route any more, and the two
places in adminConfig that concatenated it into the built-in set
- the health endpoint's vertex line, and the env vars documented for it
AI_PROVIDER=vertex now says where to configure the model instead of quietly
becoming something else. The Google STT and TTS paths keyed off the same
variable are untouched; neither ever used this SDK.
Verified after deploy: provider litellm, the assistant answers with 8 sources,
/api/models returns 10, and @aws-sdk/s3-request-presigner — which documents.js
needs for presigned MinIO URLs — is still declared and resolvable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
685 lines
27 KiB
JavaScript
685 lines
27 KiB
JavaScript
// ============================================================
|
|
// AI.JS — AI client
|
|
// Primary path: LiteLLM / OpenAI-compatible base URL via LITELLM_API_BASE.
|
|
// Legacy direct providers remain available only when explicitly selected.
|
|
// ============================================================
|
|
|
|
const { OpenAI } = require('openai');
|
|
const { FALLBACK_MODEL, getBedrockModelId, getBedrockMaxOut } = require('./models');
|
|
const logger = require('./logger');
|
|
const { resolveGenerationOptions, addReasoningOptions } = require('./generationOptions');
|
|
|
|
var activeProvider = process.env.AI_PROVIDER || (process.env.LITELLM_API_BASE ? 'litellm' : 'openrouter');
|
|
|
|
// ============================================================
|
|
// OPENROUTER CLIENT (default)
|
|
// ============================================================
|
|
var openrouter = null;
|
|
if (process.env.OPENROUTER_API_KEY) {
|
|
openrouter = new OpenAI({
|
|
baseURL: 'https://openrouter.ai/api/v1',
|
|
apiKey: process.env.OPENROUTER_API_KEY,
|
|
defaultHeaders: {
|
|
'HTTP-Referer': process.env.APP_URL || 'http://localhost:3000',
|
|
'X-Title': 'Pediatric AI Scribe'
|
|
}
|
|
});
|
|
console.log('✅ OpenRouter: configured');
|
|
}
|
|
|
|
// ============================================================
|
|
// AWS BEDROCK CLIENT (optional, HIPAA compliant)
|
|
// ============================================================
|
|
var bedrockClient = null;
|
|
if (process.env.AWS_BEDROCK_REGION) {
|
|
try {
|
|
var BedrockModule = require('@aws-sdk/client-bedrock-runtime');
|
|
var BedrockRuntimeClient = BedrockModule.BedrockRuntimeClient;
|
|
bedrockClient = new BedrockRuntimeClient({
|
|
region: process.env.AWS_BEDROCK_REGION || 'us-east-1',
|
|
credentials: process.env.AWS_ACCESS_KEY_ID ? {
|
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
|
} : undefined
|
|
});
|
|
activeProvider = 'bedrock';
|
|
console.log('✅ AWS Bedrock: configured (region: ' + process.env.AWS_BEDROCK_REGION + ')');
|
|
} catch (e) {
|
|
console.log('⚠️ AWS Bedrock: SDK not installed. Install with: npm install @aws-sdk/client-bedrock-runtime');
|
|
console.log('⚠️ Falling back to OpenRouter');
|
|
activeProvider = 'openrouter';
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// AZURE OPENAI CLIENT (optional, HIPAA compliant)
|
|
// ============================================================
|
|
var azureClient = null;
|
|
if (process.env.AZURE_OPENAI_ENDPOINT) {
|
|
try {
|
|
azureClient = new OpenAI({
|
|
apiKey: process.env.AZURE_OPENAI_API_KEY,
|
|
baseURL: process.env.AZURE_OPENAI_ENDPOINT.replace(/\/+$/, '') + '/openai/deployments/' + (process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini'),
|
|
defaultQuery: { 'api-version': process.env.AZURE_OPENAI_API_VERSION || '2024-08-01-preview' },
|
|
defaultHeaders: { 'api-key': process.env.AZURE_OPENAI_API_KEY }
|
|
});
|
|
activeProvider = 'azure';
|
|
console.log('✅ Azure OpenAI: configured (deployment: ' + (process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini') + ')');
|
|
} catch (e) {
|
|
console.log('⚠️ Azure OpenAI: configuration failed:', e.message);
|
|
console.log('⚠️ Falling back to OpenRouter');
|
|
activeProvider = 'openrouter';
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// LITELLM CLIENT (optional — OpenAI-compatible proxy)
|
|
// ============================================================
|
|
var litellmClient = null;
|
|
if (process.env.LITELLM_API_BASE) {
|
|
try {
|
|
litellmClient = new OpenAI({
|
|
baseURL: process.env.LITELLM_API_BASE.replace(/\/+$/, ''),
|
|
apiKey: process.env.LITELLM_API_KEY || process.env.LITELLM_MASTER_KEY || process.env.OPENAI_API_KEY || 'sk-litellm'
|
|
});
|
|
activeProvider = 'litellm';
|
|
console.log('✅ LiteLLM: configured (base: ' + process.env.LITELLM_API_BASE + ')');
|
|
} catch (e) {
|
|
console.log('⚠️ LiteLLM: configuration failed:', e.message);
|
|
console.log('⚠️ Falling back to OpenRouter');
|
|
activeProvider = 'openrouter';
|
|
}
|
|
}
|
|
|
|
// Force provider from env if explicitly set
|
|
if (process.env.AI_PROVIDER) {
|
|
activeProvider = process.env.AI_PROVIDER;
|
|
}
|
|
|
|
// Validate: if chosen provider has no client, fall back
|
|
if (activeProvider === 'bedrock' && !bedrockClient) {
|
|
console.log('⚠️ Bedrock selected but not available. Falling back to OpenRouter.');
|
|
activeProvider = 'openrouter';
|
|
}
|
|
if (activeProvider === 'azure' && !azureClient) {
|
|
console.log('⚠️ Azure selected but not available. Falling back to OpenRouter.');
|
|
activeProvider = 'openrouter';
|
|
}
|
|
// Vertex is reached through LiteLLM now, not through the Google SDK, which was
|
|
// removed along with the two advisories it carried. AI_PROVIDER=vertex is
|
|
// therefore not a thing this app can be; point the operator at the gateway
|
|
// rather than silently doing something else.
|
|
if (activeProvider === 'vertex') {
|
|
console.log('⚠️ AI_PROVIDER=vertex is no longer supported directly. Configure the model through LiteLLM instead. Falling back to OpenRouter.');
|
|
activeProvider = 'openrouter';
|
|
}
|
|
if (activeProvider === 'litellm' && !litellmClient) {
|
|
console.log('⚠️ LiteLLM selected but not available. Falling back to OpenRouter.');
|
|
activeProvider = 'openrouter';
|
|
}
|
|
if (activeProvider === 'openrouter' && !openrouter) {
|
|
console.error('❌ OpenRouter selected but OPENROUTER_API_KEY not set!');
|
|
}
|
|
|
|
console.log('🤖 Active AI provider:', activeProvider);
|
|
|
|
// ============================================================
|
|
// IMAGE ATTACHMENTS — multimodal content for the latest user message.
|
|
// Only OpenAI-compatible providers accept content-part messages. The legacy
|
|
// direct adapters (Bedrock/Vertex) cannot take image parts, so requests with
|
|
// images are refused with a clear 400 before any provider contact.
|
|
// ============================================================
|
|
var MULTIMODAL_PROVIDERS = ['litellm', 'openrouter', 'azure'];
|
|
|
|
function applyImageAttachments(messages, images) {
|
|
if (!Array.isArray(images) || !images.length) return messages;
|
|
// One-line observability: confirms image parts reach the provider request.
|
|
console.log('[clinical-ai] attaching ' + images.length + ' image part(s) to the provider request');
|
|
if (MULTIMODAL_PROVIDERS.indexOf(activeProvider) === -1) {
|
|
var unsupported = new Error('Image attachments require an OpenAI-compatible provider (LiteLLM, OpenRouter, or Azure). The active provider cannot accept image input, so no request was sent.');
|
|
unsupported.statusCode = 400;
|
|
unsupported.code = 'IMAGES_UNSUPPORTED_PROVIDER';
|
|
throw unsupported;
|
|
}
|
|
var out = messages.slice();
|
|
var index = out.length - 1;
|
|
while (index >= 0 && !(out[index] && out[index].role === 'user')) index--;
|
|
if (index < 0) return out; // No user message to attach to.
|
|
var message = out[index];
|
|
var content = Array.isArray(message.content) ? message.content.slice() : [{ type: 'text', text: String(message.content || '') }];
|
|
images.forEach(function (image) {
|
|
content.push({ type: 'image_url', image_url: { url: 'data:' + image.mimeType + ';base64,' + image.dataBase64 } });
|
|
});
|
|
out[index] = Object.assign({}, message, { content: content });
|
|
return out;
|
|
}
|
|
|
|
// Preserve unrecognized provider statuses; only explicit successful stops are complete.
|
|
function normalizeFinishReason(reason) {
|
|
if (typeof reason !== 'string') return reason ?? null;
|
|
switch (reason.toLowerCase()) {
|
|
case 'max_tokens': return 'length';
|
|
case 'stop':
|
|
case 'end_turn':
|
|
case 'stop_sequence': return 'stop';
|
|
default: return reason;
|
|
}
|
|
}
|
|
|
|
function assertToolProvider(options) {
|
|
if ((options.tools || options.toolChoice) && !['litellm', 'openrouter', 'azure'].includes(activeProvider)) {
|
|
throw new Error('Tools require an OpenAI-compatible provider; no provider request was sent');
|
|
}
|
|
}
|
|
|
|
function addToolOptions(request, generation) {
|
|
if (generation && generation.tools) {
|
|
request.tools = generation.tools;
|
|
request.tool_choice = generation.toolChoice || 'auto';
|
|
request.parallel_tool_calls = false;
|
|
}
|
|
return request;
|
|
}
|
|
|
|
// ============================================================
|
|
// CALL OPENROUTER
|
|
// ============================================================
|
|
async function callOpenRouter(messages, model, temperature, maxTokens, generation) {
|
|
if (!openrouter) throw new Error('OpenRouter not configured. Set OPENROUTER_API_KEY in .env');
|
|
|
|
var completion = await openrouter.chat.completions.create(addToolOptions({
|
|
model: model,
|
|
messages: messages,
|
|
temperature: temperature,
|
|
max_tokens: maxTokens
|
|
}, generation));
|
|
|
|
return {
|
|
success: true,
|
|
content: completion.choices[0].message.content,
|
|
...(completion.choices[0].message.tool_calls ? { toolCalls: completion.choices[0].message.tool_calls } : {}),
|
|
model: model,
|
|
provider: 'openrouter',
|
|
usage: completion.usage || null,
|
|
finishReason: completion.choices[0].finish_reason || null
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// CALL AZURE OPENAI
|
|
// ============================================================
|
|
async function callAzure(messages, model, temperature, maxTokens, generation) {
|
|
if (!azureClient) throw new Error('Azure OpenAI not configured');
|
|
|
|
var completion = await azureClient.chat.completions.create(addToolOptions({
|
|
model: model,
|
|
messages: messages,
|
|
temperature: temperature,
|
|
max_tokens: maxTokens
|
|
}, generation));
|
|
|
|
return {
|
|
success: true,
|
|
content: completion.choices[0].message.content,
|
|
...(completion.choices[0].message.tool_calls ? { toolCalls: completion.choices[0].message.tool_calls } : {}),
|
|
model: model,
|
|
provider: 'azure',
|
|
usage: completion.usage || null,
|
|
finishReason: completion.choices[0].finish_reason || null
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// CALL AWS BEDROCK
|
|
// Anthropic models use InvokeModel (Messages API)
|
|
// All other models use Converse API (unified cross-model API)
|
|
// ============================================================
|
|
async function callBedrock(messages, model, temperature, maxTokens) {
|
|
if (!bedrockClient) throw new Error('AWS Bedrock not configured');
|
|
|
|
var BedrockModule = require('@aws-sdk/client-bedrock-runtime');
|
|
var modelId = getBedrockModelId(model);
|
|
var isAnthropic = modelId.indexOf('anthropic.') !== -1;
|
|
|
|
// Clamp maxTokens to model limit if set
|
|
var modelMax = getBedrockMaxOut(model);
|
|
if (modelMax && maxTokens > modelMax) maxTokens = modelMax;
|
|
|
|
// Separate system message from chat messages
|
|
var systemMsg = '';
|
|
var chatMessages = [];
|
|
messages.forEach(function(m) {
|
|
if (m.role === 'system') {
|
|
systemMsg += (systemMsg ? '\n' : '') + m.content;
|
|
} else {
|
|
chatMessages.push({ role: m.role, content: m.content });
|
|
}
|
|
});
|
|
|
|
if (isAnthropic) {
|
|
// Anthropic Messages API format (native, best performance)
|
|
var InvokeModelCommand = BedrockModule.InvokeModelCommand;
|
|
var body = {
|
|
anthropic_version: 'bedrock-2023-05-31',
|
|
max_tokens: maxTokens,
|
|
temperature: temperature,
|
|
messages: chatMessages
|
|
};
|
|
if (systemMsg) body.system = systemMsg;
|
|
|
|
var command = new InvokeModelCommand({
|
|
modelId: modelId,
|
|
contentType: 'application/json',
|
|
accept: 'application/json',
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
var response = await bedrockClient.send(command);
|
|
var responseBody = JSON.parse(new TextDecoder().decode(response.body));
|
|
|
|
// Debug: log response structure for troubleshooting
|
|
if (Array.isArray(responseBody.content)) {
|
|
console.log('[Bedrock] Model:', modelId, '| Blocks:', responseBody.content.length, '|', responseBody.content.map(function(b) { return b.type + '(' + (b.text ? b.text.length : 0) + ')'; }).join(', '), '| stop_reason:', responseBody.stop_reason);
|
|
}
|
|
|
|
// Opus 4.6+ may return multiple content blocks (thinking + text).
|
|
// Extract all text blocks, skipping thinking blocks.
|
|
var textContent = '';
|
|
if (Array.isArray(responseBody.content)) {
|
|
responseBody.content.forEach(function(block) {
|
|
if (block.type === 'text' && block.text) textContent += block.text;
|
|
});
|
|
// Fallback: if no text blocks found, try first block
|
|
if (!textContent && responseBody.content[0]) {
|
|
textContent = responseBody.content[0].text || '';
|
|
}
|
|
} else {
|
|
textContent = responseBody.content || '';
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
content: textContent,
|
|
finishReason: normalizeFinishReason(responseBody.stop_reason),
|
|
model: modelId,
|
|
provider: 'bedrock',
|
|
usage: {
|
|
prompt_tokens: responseBody.usage ? responseBody.usage.input_tokens : 0,
|
|
completion_tokens: responseBody.usage ? responseBody.usage.output_tokens : 0
|
|
}
|
|
};
|
|
} else {
|
|
// Converse API — unified API for all non-Anthropic Bedrock models
|
|
var ConverseCommand = BedrockModule.ConverseCommand;
|
|
|
|
var converseMessages = chatMessages.map(function(m) {
|
|
return { role: m.role, content: [{ text: m.content }] };
|
|
});
|
|
|
|
var converseParams = {
|
|
modelId: modelId,
|
|
messages: converseMessages,
|
|
inferenceConfig: {
|
|
maxTokens: maxTokens,
|
|
temperature: temperature
|
|
}
|
|
};
|
|
if (systemMsg) {
|
|
converseParams.system = [{ text: systemMsg }];
|
|
}
|
|
|
|
var converseResponse = await bedrockClient.send(new ConverseCommand(converseParams));
|
|
|
|
var outputText = '';
|
|
if (converseResponse.output && converseResponse.output.message && converseResponse.output.message.content) {
|
|
converseResponse.output.message.content.forEach(function(block) {
|
|
if (block.text) outputText += block.text;
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
content: outputText,
|
|
finishReason: normalizeFinishReason(converseResponse.stopReason),
|
|
model: modelId,
|
|
provider: 'bedrock',
|
|
usage: {
|
|
prompt_tokens: converseResponse.usage ? converseResponse.usage.inputTokens : 0,
|
|
completion_tokens: converseResponse.usage ? converseResponse.usage.outputTokens : 0
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// CALL LITELLM (OpenAI-compatible proxy)
|
|
// ============================================================
|
|
async function callLiteLLM(messages, model, temperature, maxTokens, generation) {
|
|
if (!litellmClient) throw new Error('LiteLLM not configured. Set LITELLM_API_BASE in .env');
|
|
|
|
var completion = await litellmClient.chat.completions.create(addToolOptions(addReasoningOptions({
|
|
model: model,
|
|
messages: messages,
|
|
temperature: temperature,
|
|
max_tokens: maxTokens
|
|
}, generation || {}), generation));
|
|
|
|
return {
|
|
success: true,
|
|
content: completion.choices[0].message.content,
|
|
...(completion.choices[0].message.tool_calls ? { toolCalls: completion.choices[0].message.tool_calls } : {}),
|
|
model: model,
|
|
provider: 'litellm',
|
|
usage: completion.usage || null,
|
|
finishReason: completion.choices[0].finish_reason || null
|
|
};
|
|
}
|
|
|
|
async function assertModelAllowed(requestedModel, options) {
|
|
options = options || {};
|
|
if (options.skipAllowlistCheck === true) return;
|
|
var db = require('../db/database');
|
|
var { getAllowedModelIds } = require('./models');
|
|
var allowed = await getAllowedModelIds(db);
|
|
if (!allowed.has(requestedModel)) {
|
|
var err = new Error('Model not permitted');
|
|
err.code = 'model_not_permitted';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function resolveModel(requestedModel) {
|
|
var model = requestedModel && String(requestedModel).trim();
|
|
if (model) return model;
|
|
return require('./models').getEffectiveDefaultModel(require('../db/database'));
|
|
}
|
|
|
|
async function callAIStream(messages, options, onToken) {
|
|
options = options || {};
|
|
var requestedModel = options.model;
|
|
var model = await resolveModel(requestedModel);
|
|
assertToolProvider(options);
|
|
var generation = Object.assign(resolveGenerationOptions(options), { tools: options.tools, toolChoice: options.toolChoice });
|
|
var temperature = generation.temperature;
|
|
var maxTokens = generation.maxTokens;
|
|
var startTime = Date.now();
|
|
await assertModelAllowed(model, options);
|
|
if (activeProvider === 'azure') {
|
|
model = process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
|
|
await assertModelAllowed(model, options);
|
|
}
|
|
|
|
messages = applyImageAttachments(messages, options.images);
|
|
|
|
var client = null;
|
|
var provider = null;
|
|
if (activeProvider === 'litellm' && litellmClient) {
|
|
client = litellmClient;
|
|
provider = 'litellm';
|
|
} else if (activeProvider === 'openrouter' && openrouter) {
|
|
client = openrouter;
|
|
provider = 'openrouter';
|
|
} else if (activeProvider === 'azure' && azureClient) {
|
|
client = azureClient;
|
|
provider = 'azure';
|
|
}
|
|
if (!client) throw new Error('Streaming is only configured for OpenAI-compatible providers');
|
|
|
|
var content = '';
|
|
var toolCalls = [];
|
|
var finishReason = null;
|
|
var stream = await client.chat.completions.create(addToolOptions(addReasoningOptions({
|
|
model: model,
|
|
messages: messages,
|
|
temperature: temperature,
|
|
max_tokens: maxTokens,
|
|
stream: true
|
|
}, generation), generation));
|
|
for await (var part of stream) {
|
|
var choice = part && part.choices && part.choices[0] ? part.choices[0] : null;
|
|
if (choice && choice.finish_reason) finishReason = choice.finish_reason;
|
|
for (var fragment of (choice && choice.delta && choice.delta.tool_calls) || []) {
|
|
if (fragment.type && fragment.type !== 'function') throw new Error('Unsupported tool stream');
|
|
if (!Number.isInteger(fragment.index) || fragment.index < 0 || fragment.index > 7) throw new Error('Invalid tool stream');
|
|
var tool = toolCalls[fragment.index] || (toolCalls[fragment.index] = { id: '', type: 'function', function: { name: '', arguments: '' } });
|
|
if (fragment.id) tool.id += fragment.id;
|
|
if (fragment.function) {
|
|
tool.function.name += fragment.function.name || '';
|
|
tool.function.arguments += fragment.function.arguments || '';
|
|
}
|
|
if (tool.function.arguments.length > 40000 || tool.function.name.length > 100 || tool.id.length > 200) throw new Error('Tool stream exceeds limit');
|
|
}
|
|
var delta = choice && choice.delta ? choice.delta.content : '';
|
|
if (!delta) continue;
|
|
content += delta;
|
|
if (typeof onToken === 'function') onToken(delta);
|
|
}
|
|
var duration = Date.now() - startTime;
|
|
logger.apiCall(null, provider + '/' + model, { model: model, duration: duration, statusCode: 200 });
|
|
return { success: true, content: content, model: model, provider: provider, duration: duration, finishReason: finishReason, ...(toolCalls.length ? { toolCalls: toolCalls.filter(Boolean) } : {}) };
|
|
}
|
|
|
|
// ============================================================
|
|
// MAIN CALL AI FUNCTION — Routes to correct provider
|
|
// ============================================================
|
|
async function callAI(messages, options) {
|
|
options = options || {};
|
|
var requestedModel = options.model;
|
|
var model = await resolveModel(requestedModel);
|
|
assertToolProvider(options);
|
|
var generation = Object.assign(resolveGenerationOptions(options), { tools: options.tools, toolChoice: options.toolChoice });
|
|
var temperature = generation.temperature;
|
|
var maxTokens = generation.maxTokens;
|
|
var startTime = Date.now();
|
|
|
|
// Server-side whitelist: reject any model the operator hasn't enabled.
|
|
// Prevents a client from passing e.g. model:"openai/o1" and draining
|
|
// the budget on a reasoning model outside the configured roster.
|
|
// Omitted selections use an enabled effective default, never a removed model.
|
|
// Admin test endpoints pass skipAllowlistCheck to test before adding.
|
|
await assertModelAllowed(model, options);
|
|
if (activeProvider === 'azure') {
|
|
model = process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
|
|
await assertModelAllowed(model, options);
|
|
}
|
|
|
|
messages = applyImageAttachments(messages, options.images);
|
|
|
|
try {
|
|
var result;
|
|
|
|
// Route to correct provider
|
|
if (activeProvider === 'bedrock' && bedrockClient) {
|
|
result = await callBedrock(messages, model, temperature, maxTokens);
|
|
} else if (activeProvider === 'azure' && azureClient) {
|
|
result = await callAzure(messages, model, temperature, maxTokens, generation);
|
|
} else if (activeProvider === 'litellm' && litellmClient) {
|
|
result = await callLiteLLM(messages, model, temperature, maxTokens, generation);
|
|
} else if (openrouter) {
|
|
result = await callOpenRouter(messages, model, temperature, maxTokens, generation);
|
|
} else {
|
|
throw new Error('No AI provider configured. Set LITELLM_API_BASE plus an API key, or explicitly configure a legacy direct provider.');
|
|
}
|
|
|
|
var duration = Date.now() - startTime;
|
|
|
|
var usage = result.usage || {};
|
|
logger.apiCall(null, activeProvider + '/' + (result.model || model), {
|
|
model: result.model || model,
|
|
tokensInput: usage.prompt_tokens || 0,
|
|
tokensOutput: usage.completion_tokens || 0,
|
|
duration: duration,
|
|
statusCode: 200
|
|
});
|
|
|
|
result.duration = duration;
|
|
return result;
|
|
|
|
} catch (err) {
|
|
duration = Date.now() - startTime;
|
|
logger.error('AI call failed', {
|
|
provider: activeProvider,
|
|
model: model,
|
|
error: err.message,
|
|
duration: duration
|
|
});
|
|
|
|
// Try fallback model (on OpenRouter or LiteLLM) — OPT-IN ONLY.
|
|
// Silent fallback is a HIPAA landmine: if the primary provider is
|
|
// BAA-covered and the fallback isn't, PHI can leak to a non-covered
|
|
// endpoint without any admin awareness. Requires admin setting
|
|
// `ai.allow_model_fallback = true` (default false).
|
|
var allowFallback = false;
|
|
try {
|
|
var _db = require('../db/database');
|
|
var setting = await _db.getSetting('ai.allow_model_fallback');
|
|
allowFallback = (setting === 'true');
|
|
} catch (_e) { allowFallback = false; }
|
|
|
|
if (allowFallback) {
|
|
if (activeProvider === 'openrouter' && model !== FALLBACK_MODEL && openrouter) {
|
|
logger.warn('Trying fallback model: ' + FALLBACK_MODEL);
|
|
try {
|
|
await assertModelAllowed(FALLBACK_MODEL, options);
|
|
var fallbackResult = await callOpenRouter(messages, FALLBACK_MODEL, temperature, maxTokens, generation);
|
|
fallbackResult.fallback = true;
|
|
fallbackResult.duration = Date.now() - startTime;
|
|
logger.info('Fallback success', { model: FALLBACK_MODEL });
|
|
return fallbackResult;
|
|
} catch (err2) {
|
|
logger.error('Fallback also failed', { error: err2.message });
|
|
throw new Error('All models failed: ' + err2.message);
|
|
}
|
|
}
|
|
|
|
if (activeProvider === 'litellm' && model !== FALLBACK_MODEL && litellmClient) {
|
|
logger.warn('Trying fallback model on LiteLLM: ' + FALLBACK_MODEL);
|
|
try {
|
|
await assertModelAllowed(FALLBACK_MODEL, options);
|
|
var litellmFallback = await callLiteLLM(messages, FALLBACK_MODEL, temperature, maxTokens, generation);
|
|
litellmFallback.fallback = true;
|
|
litellmFallback.duration = Date.now() - startTime;
|
|
logger.info('LiteLLM fallback success', { model: FALLBACK_MODEL });
|
|
return litellmFallback;
|
|
} catch (err3) {
|
|
logger.error('LiteLLM fallback also failed', { error: err3.message });
|
|
throw new Error('All models failed: ' + err3.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// DISCOVER MODELS — Query provider APIs for available models
|
|
// Used by admin panel to search & select models dynamically
|
|
// ============================================================
|
|
async function discoverModels() {
|
|
var discovered = [];
|
|
|
|
if (activeProvider === 'litellm' && litellmClient) {
|
|
try {
|
|
var models = await litellmClient.models.list();
|
|
if (models && models.data) {
|
|
models.data.forEach(function(m) {
|
|
discovered.push({
|
|
id: m.id,
|
|
name: m.id,
|
|
cost: '?',
|
|
category: 'smart',
|
|
tag: 'LITELLM',
|
|
source: 'litellm-api'
|
|
});
|
|
});
|
|
}
|
|
} catch (e) {
|
|
logger.warn('LiteLLM model discovery failed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
|
|
if (activeProvider === 'openrouter' && openrouter) {
|
|
try {
|
|
var axios = require('axios');
|
|
var resp = await axios.get('https://openrouter.ai/api/v1/models', {
|
|
headers: { 'Authorization': 'Bearer ' + process.env.OPENROUTER_API_KEY }
|
|
});
|
|
if (resp.data && resp.data.data) {
|
|
resp.data.data.forEach(function(m) {
|
|
var pricing = m.pricing || {};
|
|
var promptCost = parseFloat(pricing.prompt || 0);
|
|
var costStr = promptCost > 0 ? '~$' + (promptCost * 1000000).toFixed(3) + '/M' : 'FREE';
|
|
discovered.push({
|
|
id: m.id,
|
|
name: m.name || m.id,
|
|
cost: costStr,
|
|
category: promptCost === 0 ? 'free' : (promptCost < 0.000003 ? 'fast' : (promptCost < 0.00001 ? 'smart' : 'premium')),
|
|
tag: promptCost === 0 ? 'FREE' : 'API',
|
|
source: 'openrouter-api'
|
|
});
|
|
});
|
|
}
|
|
} catch (e) {
|
|
logger.warn('OpenRouter model discovery failed: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Bedrock: try live ListFoundationModels, fall back to built-in list
|
|
if (activeProvider === 'bedrock') {
|
|
var bedrockDiscovered = false;
|
|
try {
|
|
var BedrockListModule = require('@aws-sdk/client-bedrock');
|
|
var listClient = new BedrockListModule.BedrockClient({
|
|
region: process.env.AWS_BEDROCK_REGION || 'us-east-1',
|
|
credentials: process.env.AWS_ACCESS_KEY_ID ? {
|
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
|
} : undefined
|
|
});
|
|
var listResp = await listClient.send(new BedrockListModule.ListFoundationModelsCommand({}));
|
|
if (listResp.modelSummaries && listResp.modelSummaries.length > 0) {
|
|
listResp.modelSummaries.forEach(function(m) {
|
|
if (m.inferenceTypesSupported && m.inferenceTypesSupported.includes('ON_DEMAND')) {
|
|
discovered.push({
|
|
id: m.modelId,
|
|
name: m.modelName || m.modelId,
|
|
cost: '?',
|
|
category: 'smart',
|
|
tag: (m.providerName || 'BEDROCK').toUpperCase(),
|
|
source: 'bedrock-api'
|
|
});
|
|
}
|
|
});
|
|
bedrockDiscovered = true;
|
|
}
|
|
} catch (e) {
|
|
logger.warn('Bedrock live discovery failed (' + e.code + ': ' + e.message + '), using built-in list');
|
|
}
|
|
if (!bedrockDiscovered) {
|
|
// Fall back to built-in BEDROCK_MODELS filtered by region
|
|
var { BEDROCK_MODELS } = require('./models');
|
|
var region = process.env.AWS_BEDROCK_REGION || 'us-east-1';
|
|
BEDROCK_MODELS.filter(function(m) {
|
|
return !m.regions || m.regions.indexOf(region) !== -1;
|
|
}).forEach(function(m) {
|
|
discovered.push(Object.assign({}, m, { source: 'bedrock-builtin' }));
|
|
});
|
|
}
|
|
}
|
|
|
|
// Azure: return built-in model list
|
|
if (activeProvider === 'azure') {
|
|
var { AZURE_MODELS } = require('./models');
|
|
AZURE_MODELS.forEach(function(m) {
|
|
discovered.push(Object.assign({}, m, { source: 'azure-builtin' }));
|
|
});
|
|
}
|
|
|
|
return discovered;
|
|
}
|
|
|
|
module.exports = { callAI, callAIStream, activeProvider, discoverModels, litellmClient, applyImageAttachments };
|