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:
parent
cfdb008208
commit
f8dd4d9f86
9 changed files with 1638 additions and 101 deletions
13
.env.example
13
.env.example
|
|
@ -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
1059
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);">
|
||||
|
|
|
|||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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'); });
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
219
src/utils/ai.js
219
src/utils/ai.js
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue