v9.1: Add Google Vertex AI + LiteLLM support, admin model management panel

- Add Vertex AI provider (Gemini models via @google-cloud/vertexai SDK)
- Add LiteLLM proxy support (OpenAI-compatible, routes to any provider)
- Admin panel: model search/discover from provider API, enable/disable, custom models, set default
- New endpoints: /config/models/discover, /config/models/add-discovered, /config/models/default
- Updated models.js with VERTEX_MODELS and LITELLM_MODELS lists
- Updated health endpoint with vertex + litellm status
This commit is contained in:
Daniel Onyejesi 2026-03-29 10:32:45 -04:00
parent cfdb008208
commit f8dd4d9f86
9 changed files with 1638 additions and 101 deletions

View file

@ -20,6 +20,19 @@ OPENROUTER_API_KEY=sk-or-v1-your-key
# AZURE_DEPLOYMENT_NAME=gpt-4o-mini
# AZURE_OPENAI_API_VERSION=2024-02-01
# Option 4: Google Vertex AI (HIPAA compliant with BAA)
# AI_PROVIDER=vertex
# GOOGLE_VERTEX_PROJECT=your-gcp-project-id
# GOOGLE_VERTEX_LOCATION=us-central1
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
# (Or use default credentials if running on GCE/GKE/Cloud Run)
# Option 5: LiteLLM Proxy (self-hosted, routes to any provider)
# AI_PROVIDER=litellm
# LITELLM_API_BASE=http://localhost:4000
# LITELLM_API_KEY=sk-litellm-your-key
# Admin can discover available models via the admin panel
# ============================================================
# TRANSCRIPTION (speech-to-text)
# ============================================================

1059
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "pediatric-ai-scribe",
"version": "9.0.0",
"version": "9.1.0",
"description": "AI-powered pediatric clinical documentation platform",
"main": "server.js",
"scripts": {
@ -39,6 +39,7 @@
"@aws-sdk/client-bedrock-runtime": "^3.700.0",
"@aws-sdk/client-transcribe-streaming": "^3.1017.0",
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0"
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@google-cloud/vertexai": "^1.9.0"
}
}

View file

@ -202,7 +202,66 @@
</div>
</div>
<!-- AI Models: configured via global model selector in header (powered by /api/models) -->
<!-- ── AI Model Management ─────────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-microchip"></i> AI Model Management</h3>
<span id="admin-model-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
<!-- Default Model -->
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<label style="font-size:13px;font-weight:600;min-width:120px;">Default Model:</label>
<select id="admin-default-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:400px;"></select>
<button id="btn-save-default-model" class="btn-sm btn-primary">Set Default</button>
</div>
<!-- Built-in Models Toggle -->
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Built-in Models (toggle to enable/disable for users)</label>
<div id="admin-builtin-models" style="display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;padding-right:4px;">
<p style="color:var(--g400);font-size:13px;">Loading...</p>
</div>
</div>
<!-- Discover Models from API -->
<div style="border-top:1px solid var(--g100);padding-top:14px;">
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Discover Models from Provider API</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="text" id="admin-model-search" placeholder="Search models (e.g. gemini, vendor-model, gpt)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;">
<button id="btn-discover-models" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search API</button>
</div>
<div id="admin-discovered-models" style="margin-top:10px;display:flex;flex-direction:column;gap:4px;max-height:400px;overflow-y:auto;">
</div>
<p id="admin-discover-hint" style="font-size:12px;color:var(--g500);margin:8px 0 0;">Click "Search API" to query your configured provider for all available models. Use the search box to filter results.</p>
</div>
<!-- Custom Model (manual) -->
<div style="border-top:1px solid var(--g100);padding-top:14px;">
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Add Custom Model (manual)</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
<input type="text" id="admin-custom-model-id" placeholder="Model ID (e.g. openai/gpt-4.1)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<input type="text" id="admin-custom-model-name" placeholder="Display name" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<input type="text" id="admin-custom-model-cost" placeholder="Cost (e.g. ~$0.01)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<select id="admin-custom-model-cat" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<option value="fast">Fast & Cheap</option>
<option value="smart" selected>Smart</option>
<option value="premium">Premium</option>
<option value="free">Free</option>
</select>
</div>
<div style="margin-top:8px;">
<button id="btn-add-custom-model" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add Model</button>
</div>
</div>
<!-- Custom Models List -->
<div id="admin-custom-models-list" style="display:flex;flex-direction:column;gap:4px;">
</div>
</div>
</div>
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
<div class="card" style="border:1px solid var(--red-light);">

View file

@ -573,3 +573,265 @@
}
})();
// ============================================================
// ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models
// ============================================================
(function() {
var modelsLoaded = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') {
if (!modelsLoaded) { loadAdminModels(); modelsLoaded = true; }
}
});
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-discover-models')) discoverModels();
if (e.target.closest('#btn-add-custom-model')) addCustomModel();
if (e.target.closest('#btn-save-default-model')) saveDefaultModel();
});
// Allow Enter key to trigger search
document.addEventListener('keydown', function(e) {
if (e.target.id === 'admin-model-search' && e.key === 'Enter') {
e.preventDefault();
discoverModels();
}
});
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function loadAdminModels() {
fetch('/api/admin/config/models', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
// Provider badge
var badge = document.getElementById('admin-model-provider-badge');
if (badge) badge.textContent = (data.provider || 'unknown').toUpperCase();
// Default model selector
var defaultSel = document.getElementById('admin-default-model');
if (defaultSel) {
defaultSel.innerHTML = '';
data.models.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name + (m.enabled === false ? ' (disabled)' : '');
defaultSel.appendChild(opt);
});
// Also add custom models
(data.custom || []).forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name + ' (custom)';
defaultSel.appendChild(opt);
});
}
// Built-in models with toggle
var container = document.getElementById('admin-builtin-models');
if (container) {
if (data.models.length === 0) {
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No built-in models for this provider</p>';
} else {
container.innerHTML = data.models.map(function(m) {
var checked = m.enabled !== false ? 'checked' : '';
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--g50);cursor:pointer;font-size:13px;">' +
'<input type="checkbox" class="admin-model-toggle" data-model-id="' + esc(m.id) + '" ' + checked + ' style="accent-color:var(--blue);">' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g600);">' + esc(m.tag || '') + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
'</label>';
}).join('');
container.querySelectorAll('.admin-model-toggle').forEach(function(cb) {
cb.addEventListener('change', function() {
toggleModel(cb.dataset.modelId, cb.checked);
});
});
}
}
// Custom models list
renderCustomModels(data.custom || []);
})
.catch(function(err) { console.error('[AdminModels] Load failed:', err); });
}
function toggleModel(modelId, enabled) {
fetch('/api/admin/config/models/toggle', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ modelId: modelId, enabled: enabled })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast(modelId + ' ' + (enabled ? 'enabled' : 'disabled'), 'success');
} else {
showToast(data.error || 'Failed', 'error');
// Revert checkbox
var cb = document.querySelector('.admin-model-toggle[data-model-id="' + modelId + '"]');
if (cb) cb.checked = !enabled;
}
})
.catch(function() { showToast('Request failed', 'error'); });
}
function discoverModels() {
var search = (document.getElementById('admin-model-search') || {}).value || '';
var container = document.getElementById('admin-discovered-models');
var hint = document.getElementById('admin-discover-hint');
if (!container) return;
container.innerHTML = '<p style="color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Querying provider API...</p>';
if (hint) hint.style.display = 'none';
fetch('/api/admin/config/models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) {
container.innerHTML = '<p style="color:var(--red);font-size:13px;">Error: ' + esc(data.error || 'Unknown error') + '</p>';
return;
}
if (!data.models || data.models.length === 0) {
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '. Try a different search term.</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models. Click + to add to your model list.</p>' +
data.models.slice(0, 100).map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-add-discovered" data-mid="' + esc(m.id) + '" data-mname="' + esc(m.name) + '" data-mcost="' + esc(m.cost) + '" data-mcat="' + esc(m.category) + '" style="padding:2px 8px;font-size:11px;min-width:28px;">+</button>' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
'<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--g100);color:var(--g600);">' + esc(m.category || '') + '</span>' +
'</div>';
}).join('');
container.querySelectorAll('.admin-add-discovered').forEach(function(btn) {
btn.addEventListener('click', function() {
addDiscoveredModel(btn.dataset.mid, btn.dataset.mname, btn.dataset.mcost, btn.dataset.mcat, btn);
});
});
})
.catch(function(err) {
container.innerHTML = '<p style="color:var(--red);font-size:13px;">Request failed: ' + esc(err.message) + '</p>';
});
}
function addDiscoveredModel(id, name, cost, category, btn) {
fetch('/api/admin/config/models/add-discovered', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ id: id, name: name, cost: cost, category: category })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('Added: ' + name, 'success');
if (btn) { btn.textContent = 'Added'; btn.disabled = true; btn.style.background = 'var(--green)'; }
loadAdminModels(); // Refresh custom list
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function() { showToast('Request failed', 'error'); });
}
function addCustomModel() {
var id = (document.getElementById('admin-custom-model-id') || {}).value || '';
var name = (document.getElementById('admin-custom-model-name') || {}).value || '';
var cost = (document.getElementById('admin-custom-model-cost') || {}).value || '';
var cat = (document.getElementById('admin-custom-model-cat') || {}).value || 'smart';
if (!id.trim() || !name.trim()) { showToast('Model ID and name required', 'error'); return; }
fetch('/api/admin/config/models/custom', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ id: id.trim(), name: name.trim(), cost: cost.trim(), category: cat })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('Custom model added: ' + name, 'success');
// Clear inputs
var el = document.getElementById('admin-custom-model-id'); if (el) el.value = '';
el = document.getElementById('admin-custom-model-name'); if (el) el.value = '';
el = document.getElementById('admin-custom-model-cost'); if (el) el.value = '';
loadAdminModels();
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function() { showToast('Request failed', 'error'); });
}
function renderCustomModels(custom) {
var container = document.getElementById('admin-custom-models-list');
if (!container) return;
if (!custom || custom.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = '<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Custom / Discovered Models</label>' +
custom.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--blue-light, #e0f0ff);color:var(--blue);">' + esc(m.tag || 'CUSTOM') + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
'<button class="btn-sm admin-delete-custom" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;">Remove</button>' +
'</div>';
}).join('');
container.querySelectorAll('.admin-delete-custom').forEach(function(btn) {
btn.addEventListener('click', function() {
deleteCustomModel(btn.dataset.mid);
});
});
}
function deleteCustomModel(modelId) {
fetch('/api/admin/config/models/custom/' + encodeURIComponent(modelId), {
method: 'DELETE',
headers: getAuthHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('Removed: ' + modelId, 'info');
loadAdminModels();
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function() { showToast('Request failed', 'error'); });
}
function saveDefaultModel() {
var sel = document.getElementById('admin-default-model');
if (!sel || !sel.value) { showToast('Select a model', 'error'); return; }
fetch('/api/admin/config/models/default', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ modelId: sel.value })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) showToast('Default model set: ' + sel.value, 'success');
else showToast(data.error || 'Failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
}
})();

View file

@ -141,11 +141,13 @@ app.get('/api/models', async (req, res) => {
const { activeProvider } = require('./src/utils/ai');
app.get('/api/health', (req, res) => {
res.json({
status: 'running', version: '9.0.0', provider: activeProvider,
status: 'running', version: '9.1.0', provider: activeProvider,
timestamp: new Date().toISOString(),
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
azure: process.env.AZURE_OPENAI_ENDPOINT ? 'configured' : 'not configured',
vertex: process.env.GOOGLE_VERTEX_PROJECT ? 'configured' : 'not configured',
litellm: process.env.LITELLM_API_BASE ? 'configured' : 'not configured',
whisper: process.env.OPENAI_API_KEY ? 'configured' : 'missing'
});
});
@ -205,7 +207,7 @@ const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log('');
console.log('==========================================');
console.log('🏥 PEDIATRIC AI SCRIBE v9.0');
console.log('🏥 PEDIATRIC AI SCRIBE v9.1');
console.log('==========================================');
console.log('🌐 http://localhost:' + PORT);
console.log('🤖 Provider: ' + activeProvider);

View file

@ -226,8 +226,15 @@ router.delete('/config/smtp', async function(req, res) {
// ── GET models config ────────────────────────────────────────────────────
router.get('/config/models', async function(req, res) {
try {
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, activeProvider } = require('../utils/models');
var providerModels = activeProvider === 'bedrock' ? BEDROCK_MODELS : (activeProvider === 'azure' ? AZURE_MODELS : OPENROUTER_MODELS);
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS, LITELLM_MODELS, activeProvider } = require('../utils/models');
var providerModels;
switch (activeProvider) {
case 'bedrock': providerModels = BEDROCK_MODELS; break;
case 'azure': providerModels = AZURE_MODELS; break;
case 'vertex': providerModels = VERTEX_MODELS; break;
case 'litellm': providerModels = LITELLM_MODELS; break;
default: providerModels = OPENROUTER_MODELS;
}
var disabledRaw = await db.getSetting('models.disabled') || '[]';
var customRaw = await db.getSetting('models.custom') || '[]';
@ -327,4 +334,55 @@ router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET discover models from provider API ─────────────────────────────
router.get('/config/models/discover', async function(req, res) {
try {
var { discoverModels } = require('../utils/ai');
var discovered = await discoverModels();
var search = (req.query.q || '').toLowerCase().trim();
if (search) {
discovered = discovered.filter(function(m) {
return m.id.toLowerCase().indexOf(search) !== -1 || m.name.toLowerCase().indexOf(search) !== -1;
});
}
res.json({ success: true, models: discovered, count: discovered.length });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST add discovered model to enabled list ─────────────────────────
router.post('/config/models/add-discovered', async function(req, res) {
try {
var { id, name, cost, category } = req.body;
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
var trimmedId = id.trim();
if (trimmedId.length > 200) return res.status(400).json({ error: 'Model ID too long (max 200 chars)' });
var validCategories = ['free', 'fast', 'smart', 'premium'];
var cat = validCategories.includes(category) ? category : 'smart';
var customRaw = await db.getSetting('models.custom') || '[]';
var custom;
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
custom = custom.filter(function(m) { return m.id !== trimmedId; });
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'DISCOVERED' });
await db.setSetting('models.custom', JSON.stringify(custom));
logger.audit(req.user.id, 'admin_model_discover_add', 'Added discovered model: ' + trimmedId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── PUT set default model ─────────────────────────────────────────────
router.put('/config/models/default', async function(req, res) {
try {
var { modelId } = req.body;
if (!modelId) return res.status(400).json({ error: 'modelId required' });
await db.setSetting('models.default', modelId.trim());
logger.audit(req.user.id, 'admin_model_default', 'Set default model: ' + modelId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

View file

@ -1,6 +1,6 @@
// ============================================================
// AI.JS — Multi-provider AI client
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
// Default: OpenRouter (set AI_PROVIDER in .env to switch)
// ============================================================
@ -71,6 +71,45 @@ if (process.env.AZURE_OPENAI_ENDPOINT) {
}
}
// ============================================================
// 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)
// ============================================================
@ -96,6 +135,14 @@ 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!');
}
@ -266,6 +313,88 @@ async function callBedrock(messages, model, temperature, maxTokens) {
}
}
// ============================================================
// 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
};
}
// ============================================================
// MAIN CALL AI FUNCTION — Routes to correct provider
// ============================================================
@ -284,10 +413,14 @@ async function callAI(messages, options) {
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, or AZURE_OPENAI_ENDPOINT in .env');
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;
@ -312,7 +445,7 @@ async function callAI(messages, options) {
duration: duration
});
// Try fallback model (only on OpenRouter — Bedrock/Azure don't have multiple models easily)
// Try fallback model (on OpenRouter or LiteLLM)
if (activeProvider === 'openrouter' && model !== FALLBACK_MODEL && openrouter) {
logger.warn('Trying fallback model: ' + FALLBACK_MODEL);
try {
@ -327,8 +460,86 @@ async function callAI(messages, options) {
}
}
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;
}
}
module.exports = { callAI, whisperClient, activeProvider };
// ============================================================
// 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);
}
}
return discovered;
}
module.exports = { callAI, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient };

View file

@ -1,5 +1,6 @@
// ============================================================
// MODELS.JS — Provider-aware model list
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
// ============================================================
var activeProvider = process.env.AI_PROVIDER || 'openrouter';
@ -109,6 +110,43 @@ var AZURE_MODELS = [
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' }
];
// ============================================================
// GOOGLE VERTEX AI MODELS
// vertexId = the model name used in Vertex AI API calls
// ============================================================
var VERTEX_MODELS = [
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', cost: '~$0.001', tag: 'BEST VALUE', category: 'fast',
vertexId: 'gemini-2.5-flash-preview-05-20' },
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', cost: '~$0.01', tag: 'SMART', category: 'premium',
vertexId: 'gemini-2.5-pro-preview-05-06' },
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', cost: '~$0.001', tag: 'FAST', category: 'fast',
vertexId: 'gemini-2.0-flash' },
{ id: 'gemini-2.0-flash-lite', name: 'Gemini 2.0 Flash Lite', cost: '~$0.0004', tag: 'CHEAPEST', category: 'fast',
vertexId: 'gemini-2.0-flash-lite' },
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', cost: '~$0.007', tag: 'RELIABLE', category: 'premium',
vertexId: 'gemini-1.5-pro-002' },
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', cost: '~$0.001', tag: 'VALUE', category: 'fast',
vertexId: 'gemini-1.5-flash-002' },
{ id: 'vendor-model-sonnet-4-6@vertex', name: 'vendor model Sonnet 4.6 (Vertex)', cost: '~$0.015', tag: 'PREMIUM', category: 'premium',
vertexId: 'vendor-model-sonnet-4-6@20250514' },
{ id: 'vendor-model-haiku-4-5@vertex', name: 'vendor model Haiku 4.5 (Vertex)', cost: '~$0.003', tag: 'FAST', category: 'smart',
vertexId: 'vendor-model-haiku-4-5@20251001' },
{ id: 'llama-3.1-405b@vertex', name: 'Llama 3.1 405B (Vertex)', cost: '~$0.005', tag: 'OPEN', category: 'smart',
vertexId: 'meta/llama-3.1-405b-instruct-maas' },
];
// ============================================================
// LITELLM MODELS (default set — admin can discover more via API)
// ============================================================
var LITELLM_MODELS = [
{ id: 'gpt-4o', name: 'GPT-4o', cost: '~$0.01', tag: 'SMART', category: 'premium' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', cost: '~$0.003', tag: 'FAST', category: 'smart' },
{ id: 'vendor-model-sonnet-4-6-20250514', name: 'vendor model Sonnet 4.6', cost: '~$0.015', tag: 'PREMIUM', category: 'premium' },
{ id: 'vendor-model-haiku-4-5-20251001', name: 'vendor model Haiku 4.5', cost: '~$0.003', tag: 'FAST', category: 'smart' },
{ id: 'gemini/gemini-2.5-flash', name: 'Gemini 2.5 Flash', cost: '~$0.001', tag: 'VALUE', category: 'fast' },
{ id: 'gemini/gemini-2.5-pro', name: 'Gemini 2.5 Pro', cost: '~$0.01', tag: 'SMART', category: 'premium' },
];
function getAvailableModels() {
switch (activeProvider) {
case 'bedrock':
@ -117,6 +155,8 @@ function getAvailableModels() {
return !m.regions || m.regions.indexOf(region) !== -1;
});
case 'azure': return AZURE_MODELS;
case 'vertex': return VERTEX_MODELS;
case 'litellm': return LITELLM_MODELS;
case 'openrouter':
default: return OPENROUTER_MODELS;
}
@ -126,6 +166,8 @@ function getDefaultModel() {
switch (activeProvider) {
case 'bedrock': return 'anthropic/vendor-model-sonnet-4-6';
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
case 'vertex': return 'gemini-2.5-flash';
case 'litellm': return 'gpt-4o-mini';
case 'openrouter':
default: return 'google/gemini-2.5-flash';
}
@ -135,6 +177,8 @@ function getFallbackModel() {
switch (activeProvider) {
case 'bedrock': return 'anthropic/vendor-model-haiku-4-5';
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
case 'vertex': return 'gemini-2.0-flash';
case 'litellm': return 'gpt-4o';
case 'openrouter':
default: return 'deepseek/deepseek-chat-v3-0324';
}
@ -150,6 +194,11 @@ function getBedrockMaxOut(modelId) {
return found && found.maxOut ? found.maxOut : null;
}
function getVertexModelId(modelId) {
var found = VERTEX_MODELS.find(function(m) { return m.id === modelId; });
return found ? found.vertexId : modelId;
}
var AVAILABLE_MODELS = getAvailableModels();
var DEFAULT_MODEL = getDefaultModel();
var FALLBACK_MODEL = getFallbackModel();
@ -186,11 +235,14 @@ module.exports = {
OPENROUTER_MODELS,
BEDROCK_MODELS,
AZURE_MODELS,
VERTEX_MODELS,
LITELLM_MODELS,
getAvailableModels,
getAvailableModelsWithOverrides,
getDefaultModel,
getFallbackModel,
getBedrockModelId,
getBedrockMaxOut,
getVertexModelId,
activeProvider
};