- 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
310 lines
11 KiB
JavaScript
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 };
|