697 lines
26 KiB
JavaScript
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 };
|