pediatric-ai-scribe-v3/src/utils/ai.js
2026-05-08 06:49:50 +02:00

697 lines
26 KiB
JavaScript

// ============================================================
// AI.JS — Multi-provider AI client
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
// Default: OpenRouter (set AI_PROVIDER in .env to switch)
// ============================================================
const { OpenAI } = require('openai');
const { DEFAULT_MODEL, FALLBACK_MODEL, getBedrockModelId, getBedrockMaxOut } = require('./models');
const logger = require('./logger');
var activeProvider = process.env.AI_PROVIDER || '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';
}
}
// ============================================================
// GOOGLE VERTEX AI CLIENT (optional, HIPAA compliant with BAA)
// ============================================================
var vertexClient = null;
if (process.env.GOOGLE_VERTEX_PROJECT) {
try {
var VertexAI = require('@google-cloud/vertexai');
vertexClient = new VertexAI.VertexAI({
project: process.env.GOOGLE_VERTEX_PROJECT,
location: process.env.GOOGLE_VERTEX_LOCATION || 'us-central1'
});
activeProvider = 'vertex';
console.log('✅ Google Vertex AI: configured (project: ' + process.env.GOOGLE_VERTEX_PROJECT + ', location: ' + (process.env.GOOGLE_VERTEX_LOCATION || 'us-central1') + ')');
} catch (e) {
console.log('⚠️ Google Vertex AI: SDK not installed. Install with: npm install @google-cloud/vertexai');
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 || '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';
}
}
// ============================================================
// WHISPER CLIENT (always OpenAI, separate from text AI)
// ============================================================
var whisperClient = null;
if (process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.startsWith('sk-')) {
whisperClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
console.log('✅ Whisper: configured');
} else {
console.log('⚠️ Whisper: not configured (set OPENAI_API_KEY)');
}
// 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';
}
if (activeProvider === 'vertex' && !vertexClient) {
console.log('⚠️ Vertex AI selected but not available. 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);
// ============================================================
// CALL OPENROUTER
// ============================================================
async function callOpenRouter(messages, model, temperature, maxTokens) {
if (!openrouter) throw new Error('OpenRouter not configured. Set OPENROUTER_API_KEY in .env');
var completion = await openrouter.chat.completions.create({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
});
return {
success: true,
content: completion.choices[0].message.content,
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) {
if (!azureClient) throw new Error('Azure OpenAI not configured');
var completion = await azureClient.chat.completions.create({
model: process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini',
messages: messages,
temperature: temperature,
max_tokens: maxTokens
});
return {
success: true,
content: completion.choices[0].message.content,
model: process.env.AZURE_DEPLOYMENT_NAME || 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,
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,
model: modelId,
provider: 'bedrock',
usage: {
prompt_tokens: converseResponse.usage ? converseResponse.usage.inputTokens : 0,
completion_tokens: converseResponse.usage ? converseResponse.usage.outputTokens : 0
}
};
}
}
// ============================================================
// CALL GOOGLE VERTEX AI
// Uses the Gemini generateContent API
// ============================================================
async function callVertex(messages, model, temperature, maxTokens) {
if (!vertexClient) throw new Error('Google Vertex AI not configured');
// Map model ID to Vertex model name
var { getVertexModelId } = require('./models');
var vertexModelId = getVertexModelId(model);
var generativeModel = vertexClient.getGenerativeModel({
model: vertexModelId,
generationConfig: {
temperature: temperature,
maxOutputTokens: maxTokens,
},
});
// Convert OpenAI-style messages to Vertex AI format
var systemInstruction = '';
var contents = [];
messages.forEach(function(m) {
if (m.role === 'system') {
systemInstruction += (systemInstruction ? '\n' : '') + m.content;
} else {
contents.push({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }]
});
}
});
var request = { contents: contents };
if (systemInstruction) {
request.systemInstruction = { parts: [{ text: systemInstruction }] };
}
var result = await generativeModel.generateContent(request);
var response = result.response;
var textContent = '';
if (response.candidates && response.candidates[0] && response.candidates[0].content) {
response.candidates[0].content.parts.forEach(function(part) {
if (part.text) textContent += part.text;
});
}
return {
success: true,
content: textContent,
model: vertexModelId,
provider: 'vertex',
usage: response.usageMetadata ? {
prompt_tokens: response.usageMetadata.promptTokenCount || 0,
completion_tokens: response.usageMetadata.candidatesTokenCount || 0
} : null
};
}
// ============================================================
// CALL LITELLM (OpenAI-compatible proxy)
// ============================================================
async function callLiteLLM(messages, model, temperature, maxTokens) {
if (!litellmClient) throw new Error('LiteLLM not configured. Set LITELLM_API_BASE in .env');
var completion = await litellmClient.chat.completions.create({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
});
return {
success: true,
content: completion.choices[0].message.content,
model: model,
provider: 'litellm',
usage: completion.usage || null,
finishReason: completion.choices[0].finish_reason || null
};
}
async function assertModelAllowed(requestedModel, options) {
options = options || {};
if (!requestedModel || options.skipAllowlistCheck) return;
try {
var db = require('../db/database');
var { getAllowedModelIds } = require('./models');
var allowed = await getAllowedModelIds(db);
if (allowed && allowed.size > 0 && !allowed.has(requestedModel)) {
var err = new Error('Model not permitted');
err.code = 'model_not_permitted';
err.model = requestedModel;
throw err;
}
} catch (e) {
if (e && e.code === 'model_not_permitted') throw e;
console.warn('[callAI] Allow-list check skipped:', e && e.message);
}
}
async function resolveModel(requestedModel) {
var model = requestedModel && String(requestedModel).trim();
if (model) return model;
try {
var db = require('../db/database');
var adminDefault = await db.getSetting('models.default');
if (adminDefault && String(adminDefault).trim()) return String(adminDefault).trim();
} catch (e) {}
return DEFAULT_MODEL;
}
async function callAIStream(messages, options, onToken) {
options = options || {};
var requestedModel = options.model;
var model = await resolveModel(requestedModel);
var temperature = options.temperature || 0.3;
var maxTokens = options.maxTokens || 4000;
var startTime = Date.now();
await assertModelAllowed(model, options);
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';
model = process.env.AZURE_DEPLOYMENT_NAME || model;
}
if (!client) throw new Error('Streaming is only configured for OpenAI-compatible providers');
var content = '';
var finishReason = null;
var stream = await client.chat.completions.create({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens,
stream: true
});
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;
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 };
}
// ============================================================
// MAIN CALL AI FUNCTION — Routes to correct provider
// ============================================================
async function callAI(messages, options) {
options = options || {};
var requestedModel = options.model;
var model = await resolveModel(requestedModel);
var temperature = options.temperature || 0.3;
var maxTokens = options.maxTokens || 4000;
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.
// Falls back to the admin default, then DEFAULT_MODEL, when no model was provided.
// Admin test endpoints pass skipAllowlistCheck to test before adding.
await assertModelAllowed(model, options);
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);
} else if (activeProvider === 'vertex' && vertexClient) {
result = await callVertex(messages, model, temperature, maxTokens);
} else if (activeProvider === 'litellm' && litellmClient) {
result = await callLiteLLM(messages, model, temperature, maxTokens);
} else if (openrouter) {
result = await callOpenRouter(messages, model, temperature, maxTokens);
} else {
throw new Error('No AI provider configured. Set OPENROUTER_API_KEY, AWS_BEDROCK_REGION, AZURE_OPENAI_ENDPOINT, GOOGLE_VERTEX_PROJECT, or LITELLM_API_BASE in .env');
}
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 {
var fallbackResult = await callOpenRouter(messages, FALLBACK_MODEL, temperature, maxTokens);
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 {
var litellmFallback = await callLiteLLM(messages, FALLBACK_MODEL, temperature, maxTokens);
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 === 'vertex' && vertexClient) {
// Vertex AI doesn't have a simple list API, return known Gemini models
var { VERTEX_MODELS } = require('./models');
VERTEX_MODELS.forEach(function(m) {
discovered.push(Object.assign({}, m, { source: 'vertex-builtin' }));
});
}
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, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient };