pediatric-ai-scribe-v3/src/utils/ai.js
Daniel d2ed08cd46 v3.0: Add multi-provider Bedrock models, newborn milestones, Converse API
- Bedrock: Add 14 non-vendor model models (Amazon Nova, Meta Llama 4, DeepSeek R1/V3,
  Mistral Large 3, Cohere Command R+, AI21 Jamba) with verified AWS model IDs
- Bedrock: Implement Converse API for non-Anthropic models (unified cross-model API),
  keep native Messages API for vendor model models (best performance)
- Milestones: Add Newborn / 1 month developmental milestones (Gross Motor, Fine Motor,
  Language, Social/Emotional, Cognitive) — previously started at 2 months
- Bump version to 3.0.0, docker-compose tag to v3.0
2026-03-23 22:13:19 +01:00

310 lines
11 KiB
JavaScript

// ============================================================
// AI.JS — Multi-provider AI client
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI
// Default: OpenRouter (set AI_PROVIDER in .env to switch)
// ============================================================
const { OpenAI } = require('openai');
const { DEFAULT_MODEL, FALLBACK_MODEL, getBedrockModelId } = 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';
}
}
// ============================================================
// 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 === '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
};
}
// ============================================================
// 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
};
}
// ============================================================
// 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.startsWith('anthropic.');
// 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));
return {
success: true,
content: responseBody.content[0].text,
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
}
};
}
}
// ============================================================
// MAIN CALL AI FUNCTION — Routes to correct provider
// ============================================================
async function callAI(messages, options) {
options = options || {};
var model = options.model || DEFAULT_MODEL;
var temperature = options.temperature || 0.3;
var maxTokens = options.maxTokens || 4000;
var startTime = Date.now();
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 (openrouter) {
result = await callOpenRouter(messages, model, temperature, maxTokens);
} else {
throw new Error('No AI provider configured. Set OPENROUTER_API_KEY, AWS_BEDROCK_REGION, or AZURE_OPENAI_ENDPOINT in .env');
}
var duration = Date.now() - startTime;
logger.info('AI call success', {
provider: activeProvider,
model: result.model,
duration: duration,
outputLength: result.content.length,
tokens: result.usage
});
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 (only on OpenRouter — Bedrock/Azure don't have multiple models easily)
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);
}
}
throw err;
}
}
module.exports = { callAI, whisperClient, activeProvider };