// ============================================================ // 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 };