feat: neonatal calculator, DOCX/PPTX/ODT/EPUB support, gateway-agnostic URL helper, TTS/STT fixes

- Add neonatal assessment calculator: GA classification (extremely preterm through
  post term), weight-for-GA percentile (AGA/SGA/LGA) using Fenton 2013 LMS data,
  birth weight category (ELBW/VLBW/LBW/normal/macrosomia)
- Add DOCX support via mammoth, PPTX/ODT/EPUB via jszip in Learning Hub content
  generator file upload
- Add gatewayUrl() helper for consistent API URL construction — handles
  LITELLM_API_BASE with or without /v1 suffix, works with any OpenAI-compatible
  gateway (LiteLLM, Bifrost, etc.)
- Fix TTS model/voice separation: discovery now tags items as MODEL or VOICE,
  auto-detects provider from voice name (Vertex, ElevenLabs, OpenAI)
- Fix STT discovery to include ElevenLabs Scribe and Chirp models
- Fix TTS discovery to include ElevenLabs and Vertex voices alongside models
- Fix admin model test to bypass allowlist check (skipAllowlistCheck) so
  discovered models can be tested before adding
- Fix Nextcloud token decryption in learningAI.js WebDAV browse and file import
- Fix admin embedding test to show DB model name instead of hardcoded default
- Fix admin STT test to use correct endpoint for Whisper models
- Add AI gateway migration guide to configuration docs
- Add Grafana dashboard JSON for Loki log visualization
This commit is contained in:
Daniel 2026-04-19 02:17:06 +02:00
parent 857ed341f5
commit 2f6e5a7d8f
12 changed files with 493 additions and 57 deletions

View file

@ -34,7 +34,7 @@ keys):
| `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Bedrock / Transcribe / Transcribe-Medical. |
| `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_VERSION` | Azure OpenAI. |
| `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI + Gemini (STT/TTS). |
| `LITELLM_API_BASE`, `LITELLM_API_KEY` | Self-hosted LiteLLM proxy. |
| `LITELLM_API_BASE`, `LITELLM_API_KEY` | OpenAI-compatible AI gateway (Bifrost, LiteLLM, or similar). |
### Speech-to-text
@ -160,3 +160,45 @@ The Admin Panel (`/admin` route, admin-only) exposes everything above plus:
provider.
- Prompt editor: live-edit any `PROMPTS.*` key.
- Test SMTP / test STT / test TTS.
## Switching AI gateways
The `LITELLM_API_BASE` and `LITELLM_API_KEY` variables work with any
OpenAI-compatible gateway — LiteLLM, Bifrost, or other proxies.
### Migration steps
1. **Set the base URL**`LITELLM_API_BASE` should include `/v1` if the
gateway serves on that path (e.g., `https://gateway.example.com/v1`).
The application normalizes double `/v1` paths internally for TTS, STT,
and embedding endpoints.
2. **Set the API key**`LITELLM_API_KEY` accepts any key format the
gateway issues (virtual keys, bearer tokens, etc.).
3. **Update model names** — Different gateways use different naming
conventions. Bifrost requires `provider/model` format
(e.g., `openrouter/vendor-model-sonnet-4.6`), while LiteLLM uses aliases
(e.g., `openrouter-vendor-model-sonnet-4.6`). Update model names in:
- Admin Panel → Models (chat models)
- Admin Panel → Settings → `stt.model` (speech-to-text)
- Admin Panel → Settings → `tts.model` (text-to-speech)
- `LITELLM_TTS_MODEL` env var (if set)
4. **Embedding model** — Set via Admin Panel → Settings →
`embeddings.model`. The embedding vector column is `VECTOR(768)`, so
any model producing 768 dimensions works without re-embedding
(e.g., `vertex/text-embedding-005`). Switching to a model with
different dimensions requires altering the column and re-embedding all
content.
5. **Restart the container**`docker compose up -d --force-recreate` to
pick up `.env` changes (a plain `restart` does not re-read `.env`).
### Verified gateways
| Gateway | Model format | Notes |
|---|---|---|
| Bifrost | `provider/model` | Virtual keys, semantic caching, MCP gateway |
| LiteLLM | Custom aliases | Requires PostgreSQL + Redis |
| Any OpenAI-compatible | Varies | Must serve `/v1/chat/completions`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/embeddings` |

58
grafana-dashboard.json Normal file
View file

@ -0,0 +1,58 @@
{
"dashboard": {
"title": "PedScribe Overview",
"tags": ["pedscribe"],
"timezone": "browser",
"refresh": "30s",
"time": {"from": "now-24h", "to": "now"},
"panels": [
{
"id": 1, "title": "Audit Events", "type": "timeseries",
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 0},
"datasource": {"type": "loki"},
"targets": [{"expr": "count_over_time({app=\"pedscribe\"} [5m])", "legendFormat": "{{action}}"}],
"fieldConfig": {"defaults": {"custom": {"drawStyle": "bars", "fillOpacity": 30}}}
},
{
"id": 2, "title": "By Category", "type": "piechart",
"gridPos": {"h": 8, "w": 8, "x": 0, "y": 8},
"datasource": {"type": "loki"},
"targets": [{"expr": "sum by (category) (count_over_time({app=\"pedscribe\"} [24h]))"}]
},
{
"id": 3, "title": "By Action", "type": "piechart",
"gridPos": {"h": 8, "w": 8, "x": 8, "y": 8},
"datasource": {"type": "loki"},
"targets": [{"expr": "sum by (action) (count_over_time({app=\"pedscribe\"} [24h]))"}]
},
{
"id": 4, "title": "Success vs Failure", "type": "piechart",
"gridPos": {"h": 8, "w": 8, "x": 16, "y": 8},
"datasource": {"type": "loki"},
"targets": [{"expr": "sum by (status) (count_over_time({app=\"pedscribe\"} [24h]))"}]
},
{
"id": 5, "title": "Auth Events", "type": "logs",
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
"datasource": {"type": "loki"},
"targets": [{"expr": "{app=\"pedscribe\", category=\"auth\"}"}],
"options": {"showTime": true, "sortOrder": "Descending", "enableLogDetails": true}
},
{
"id": 6, "title": "Clinical Events", "type": "logs",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
"datasource": {"type": "loki"},
"targets": [{"expr": "{app=\"pedscribe\", category=\"clinical\"}"}],
"options": {"showTime": true, "sortOrder": "Descending", "enableLogDetails": true}
},
{
"id": 7, "title": "All Logs", "type": "logs",
"gridPos": {"h": 10, "w": 24, "x": 0, "y": 24},
"datasource": {"type": "loki"},
"targets": [{"expr": "{app=\"pedscribe\"}"}],
"options": {"showTime": true, "sortOrder": "Descending", "enableLogDetails": true}
}
]
},
"overwrite": true
}

View file

@ -33,6 +33,7 @@
"express-rate-limit": "^7.4.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.8.0",
"multer": "^1.4.5-lts.1",
"node-pg-migrate": "^7.7.0",
"nodemailer": "^8.0.5",

View file

@ -15,6 +15,7 @@
<button class="calc-nav-pill" data-calc="resus">Resus Meds</button>
<button class="calc-nav-pill" data-calc="gcs">GCS</button>
<button class="calc-nav-pill" data-calc="equipment">Equipment</button>
<button class="calc-nav-pill" data-calc="neonatal">Neonatal</button>
</div>
<!-- ═══════════ BP PERCENTILE CALCULATOR ═══════════ -->
@ -636,3 +637,39 @@
</div>
</div>
</div>
<!-- ═══════════ NEONATAL ASSESSMENT ═══════════ -->
<div id="calc-neonatal" class="calc-panel hidden">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-baby"></i> Neonatal Assessment</h3>
<span style="font-size:11px;color:var(--g500);">Fenton 2013 / WHO Classification</span>
</div>
<div style="padding:16px;">
<p style="font-size:12px;color:var(--g500);margin:0 0 14px;">Gestational age classification, birth weight assessment (AGA/SGA/LGA), and prematurity category.</p>
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:16px;">
<div class="calc-field" style="flex:1;min-width:140px;">
<label>GA (weeks)</label>
<input type="number" id="neo-ga-weeks" min="22" max="44" step="1" placeholder="e.g. 34">
</div>
<div class="calc-field" style="flex:1;min-width:140px;">
<label>GA (days)</label>
<input type="number" id="neo-ga-days" min="0" max="6" step="1" placeholder="0-6">
</div>
<div class="calc-field" style="flex:1;min-width:140px;">
<label>Birth Weight (grams)</label>
<input type="number" id="neo-weight" min="200" max="6000" step="1" placeholder="e.g. 2500">
</div>
<div class="calc-field" style="flex:1;min-width:120px;">
<label>Sex</label>
<select id="neo-sex">
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
</div>
<button id="btn-neo-assess" class="btn-primary" style="margin-bottom:16px;">Assess</button>
<div id="neo-result"></div>
</div>
</div>
</div>

View file

@ -1088,9 +1088,12 @@
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices (provider: ' + esc(data.provider) + ')</p>' +
items.slice(0, 100).map(function(v) {
var isModel = (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1;
var setType = isModel ? 'model' : 'voice';
var badge = isModel ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>';
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-tts-set-btn" data-id="' + esc(v.id) + '" data-type="voice" style="padding:2px 8px;font-size:11px;">Set</button>' +
'<span style="flex:1;">' + esc(v.name) + '</span>' +
'<button class="btn-sm btn-primary admin-tts-set-btn" data-id="' + esc(v.id) + '" data-type="' + setType + '" style="padding:2px 8px;font-size:11px;">Set</button>' +
'<span style="flex:1;">' + esc(v.name) + badge + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(v.source || '') + '</span>' +
'</div>';
}).join('');

View file

@ -2217,3 +2217,154 @@
}
}
})();
// ============================================================
// NEONATAL ASSESSMENT
// ============================================================
(function() {
document.addEventListener('click', function(e) {
if (e.target.id === 'btn-neo-assess' || e.target.closest('#btn-neo-assess')) neoAssess();
});
// Fenton 2013 LMS data (weight in grams, GA in weeks)
var fentonLMS = {
male: {
22:{L:0.21,M:496,S:0.17},23:{L:0.21,M:575,S:0.17},24:{L:0.21,M:660,S:0.17},25:{L:0.21,M:762,S:0.16},
26:{L:0.21,M:870,S:0.16},27:{L:0.20,M:993,S:0.15},28:{L:0.20,M:1124,S:0.15},29:{L:0.19,M:1272,S:0.14},
30:{L:0.18,M:1430,S:0.14},31:{L:0.17,M:1607,S:0.14},32:{L:0.15,M:1795,S:0.14},33:{L:0.13,M:2008,S:0.13},
34:{L:0.12,M:2230,S:0.13},35:{L:0.10,M:2467,S:0.13},36:{L:0.08,M:2710,S:0.13},37:{L:0.06,M:2948,S:0.12},
38:{L:0.04,M:3195,S:0.12},39:{L:0.02,M:3380,S:0.12},40:{L:0.01,M:3530,S:0.12},41:{L:0.00,M:3660,S:0.12},
42:{L:-0.02,M:3820,S:0.12}
},
female: {
22:{L:0.23,M:474,S:0.17},23:{L:0.22,M:538,S:0.17},24:{L:0.22,M:610,S:0.17},25:{L:0.22,M:705,S:0.16},
26:{L:0.22,M:810,S:0.16},27:{L:0.21,M:920,S:0.15},28:{L:0.21,M:1040,S:0.15},29:{L:0.20,M:1178,S:0.14},
30:{L:0.19,M:1330,S:0.14},31:{L:0.18,M:1500,S:0.14},32:{L:0.16,M:1680,S:0.14},33:{L:0.14,M:1880,S:0.13},
34:{L:0.12,M:2090,S:0.13},35:{L:0.10,M:2310,S:0.13},36:{L:0.08,M:2540,S:0.13},37:{L:0.06,M:2766,S:0.12},
38:{L:0.04,M:3000,S:0.12},39:{L:0.02,M:3180,S:0.12},40:{L:0.01,M:3340,S:0.12},41:{L:0.00,M:3480,S:0.12},
42:{L:-0.02,M:3630,S:0.12}
}
};
function interpolateLMS(table, val) {
var keys = Object.keys(table).map(Number).sort(function(a,b){return a-b;});
if (val <= keys[0]) return table[keys[0]];
if (val >= keys[keys.length-1]) return table[keys[keys.length-1]];
for (var i = 0; i < keys.length - 1; i++) {
if (val >= keys[i] && val <= keys[i+1]) {
var t = (val - keys[i]) / (keys[i+1] - keys[i]);
var a = table[keys[i]], b = table[keys[i+1]];
return { L: a.L + t*(b.L-a.L), M: a.M + t*(b.M-a.M), S: a.S + t*(b.S-a.S) };
}
}
return table[keys[0]];
}
function calcZ(value, L, M, S) {
if (Math.abs(L) < 0.001) return Math.log(value / M) / S;
return (Math.pow(value / M, L) - 1) / (L * S);
}
function zToPercentile(z) {
// Approximation of the standard normal CDF
var a1=0.254829592, a2=-0.284496736, a3=1.421413741, a4=-1.453152027, a5=1.061405429, p=0.3275911;
var sign = z < 0 ? -1 : 1;
var x = Math.abs(z) / Math.sqrt(2);
var t = 1 / (1 + p * x);
var y = 1 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t * Math.exp(-x*x);
return Math.round(((1 + sign * y) / 2) * 1000) / 10;
}
function classifyGA(weeks, days) {
var total = weeks + (days || 0) / 7;
if (total < 28) return { label: 'Extremely Preterm', color: '#dc2626', icon: 'fa-triangle-exclamation' };
if (total < 32) return { label: 'Very Preterm', color: '#ea580c', icon: 'fa-triangle-exclamation' };
if (total < 34) return { label: 'Moderate Preterm', color: '#d97706', icon: 'fa-circle-exclamation' };
if (total < 37) return { label: 'Late Preterm', color: '#ca8a04', icon: 'fa-circle-info' };
if (total < 39) return { label: 'Early Term', color: '#2563eb', icon: 'fa-circle-info' };
if (total < 41) return { label: 'Full Term', color: '#16a34a', icon: 'fa-circle-check' };
if (total < 42) return { label: 'Late Term', color: '#d97706', icon: 'fa-circle-info' };
return { label: 'Post Term', color: '#dc2626', icon: 'fa-triangle-exclamation' };
}
function classifyWeight(percentile) {
if (percentile < 3) return { label: 'Severely SGA', color: '#dc2626', detail: '<3rd percentile' };
if (percentile < 10) return { label: 'SGA', color: '#ea580c', detail: '<10th percentile' };
if (percentile > 97) return { label: 'Severely LGA', color: '#dc2626', detail: '>97th percentile' };
if (percentile > 90) return { label: 'LGA', color: '#ea580c', detail: '>90th percentile' };
return { label: 'AGA', color: '#16a34a', detail: '10th-90th percentile' };
}
function classifyBirthWeight(grams) {
if (grams < 1000) return { label: 'Extremely Low Birth Weight (ELBW)', color: '#dc2626' };
if (grams < 1500) return { label: 'Very Low Birth Weight (VLBW)', color: '#ea580c' };
if (grams < 2500) return { label: 'Low Birth Weight (LBW)', color: '#d97706' };
if (grams <= 4000) return { label: 'Normal Birth Weight', color: '#16a34a' };
return { label: 'Macrosomia (>4000g)', color: '#ea580c' };
}
function neoAssess() {
var weeks = parseInt(document.getElementById('neo-ga-weeks').value);
var days = parseInt(document.getElementById('neo-ga-days').value) || 0;
var weight = parseFloat(document.getElementById('neo-weight').value);
var sex = document.getElementById('neo-sex').value;
var resultDiv = document.getElementById('neo-result');
if (!weeks || !weight) {
resultDiv.innerHTML = '<p style="color:var(--red);font-size:13px;">Enter GA and birth weight.</p>';
return;
}
var gaDecimal = weeks + days / 7;
var gaClass = classifyGA(weeks, days);
var bwClass = classifyBirthWeight(weight);
// Calculate percentile using Fenton LMS
var lms = interpolateLMS(fentonLMS[sex], gaDecimal);
var z = calcZ(weight, lms.L, lms.M, lms.S);
var percentile = zToPercentile(z);
var wtClass = classifyWeight(percentile);
var expectedWeight = Math.round(lms.M);
var html = '';
// GA Classification
html += '<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">';
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + gaClass.color + '10;border:1.5px solid ' + gaClass.color + '30;">';
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Gestational Age Classification</div>';
html += '<div style="font-size:18px;font-weight:700;color:' + gaClass.color + ';"><i class="fas ' + gaClass.icon + '"></i> ' + gaClass.label + '</div>';
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + weeks + ' weeks ' + days + ' days (' + gaDecimal.toFixed(1) + ' weeks)</div>';
html += '</div>';
// Weight Classification (AGA/SGA/LGA)
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + wtClass.color + '10;border:1.5px solid ' + wtClass.color + '30;">';
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Weight for Gestational Age</div>';
html += '<div style="font-size:18px;font-weight:700;color:' + wtClass.color + ';">' + wtClass.label + '</div>';
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + percentile.toFixed(1) + 'th percentile (' + wtClass.detail + ')</div>';
html += '</div>';
html += '</div>';
// Birth Weight Category
html += '<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;">';
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:' + bwClass.color + '10;border:1.5px solid ' + bwClass.color + '30;">';
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Birth Weight Category</div>';
html += '<div style="font-size:18px;font-weight:700;color:' + bwClass.color + ';">' + bwClass.label + '</div>';
html += '<div style="font-size:13px;color:var(--g600);margin-top:4px;">' + weight + 'g (' + (weight/1000).toFixed(2) + ' kg)</div>';
html += '</div>';
// Stats
html += '<div style="flex:1;min-width:200px;padding:14px;border-radius:10px;background:var(--g50);border:1.5px solid var(--g200);">';
html += '<div style="font-size:12px;color:var(--g500);margin-bottom:4px;">Fenton Growth Data (' + (sex === 'male' ? 'Male' : 'Female') + ')</div>';
html += '<div style="font-size:13px;color:var(--g700);line-height:1.8;">';
html += '<strong>Expected weight (50th %ile):</strong> ' + expectedWeight + 'g<br>';
html += '<strong>Z-score:</strong> ' + z.toFixed(2) + '<br>';
html += '<strong>Percentile:</strong> ' + percentile.toFixed(1) + '%';
html += '</div></div>';
html += '</div>';
// Reference
html += '<p style="font-size:11px;color:var(--g400);margin:8px 0 0;">Fenton TR, Kim JH. A systematic review and meta-analysis to revise the Fenton growth chart for preterm infants. BMC Pediatrics 2013;13:59. GA classification per ACOG/AAP definitions.</p>';
resultDiv.innerHTML = html;
}
})();

View file

@ -8,6 +8,7 @@ var db = require('../db/database');
var { authMiddleware, adminMiddleware } = require('../middleware/auth');
var PROMPTS = require('../utils/prompts');
var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors');
router.use(authMiddleware);
@ -378,7 +379,7 @@ router.post('/config/models/test', async function(req, res) {
var start = Date.now();
var result = await callAI(
[{ role: 'user', content: 'Reply with only the word: OK' }],
{ model: modelId, maxTokens: 20, temperature: 0 }
{ model: modelId, maxTokens: 20, temperature: 0, skipAllowlistCheck: true }
);
res.json({
success: true,
@ -473,25 +474,44 @@ router.get('/config/tts/discover', async function(req, res) {
}
if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
// base URL handled by gatewayUrl() helper
var lHeaders = {};
if (process.env.LITELLM_API_KEY) lHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
try {
var lResp = await axios.get(base + '/v1/models', { headers: lHeaders, timeout: 10000 });
var lResp = await axios.get(gatewayUrl('/models'), { headers: lHeaders, timeout: 10000 });
if (lResp.data && lResp.data.data) {
lResp.data.data.forEach(function(m) {
var mid = (m.id || '').toLowerCase();
if (mid.indexOf('tts') !== -1 || mid.indexOf('speech') !== -1 || mid.indexOf('voice') !== -1) {
discovered.push({ id: m.id, name: m.id, source: 'litellm-api' });
if (mid.indexOf('tts') !== -1 || mid.indexOf('speech') !== -1 || mid.indexOf('voice') !== -1 || mid.indexOf('eleven') !== -1 || mid.indexOf('playai') !== -1) {
discovered.push({ id: m.id, name: m.id, source: 'gateway-api' });
}
});
}
} catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); }
['alloy','echo','fable','onyx','nova','shimmer'].forEach(function(v) {
if (!discovered.find(function(d) { return d.id === v; })) {
discovered.push({ id: v, name: v + ' (OpenAI voice)', source: 'builtin' });
}
// OpenAI voices
['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral'].forEach(function(v) {
discovered.push({ id: v, name: v + ' (OpenAI voice)', source: 'openai-voices' });
});
// Vertex Gemini TTS voices
['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'].forEach(function(v) {
discovered.push({ id: v, name: v + ' (Vertex voice)', source: 'vertex-voices' });
});
// ElevenLabs voices — fetch from API if key available
if (process.env.ELEVENLABS_API_KEY) {
try {
var elResp = await axios.get('https://api.elevenlabs.io/v1/voices', {
headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY }, timeout: 10000
});
if (elResp.data && elResp.data.voices) {
elResp.data.voices.forEach(function(v) {
discovered.push({ id: v.voice_id, name: v.name + ' (ElevenLabs)', source: 'elevenlabs-api' });
});
}
} catch (e) { logger.warn('ElevenLabs voice fetch failed: ' + e.message); }
}
}
if (provider === 'elevenlabs' && process.env.ELEVENLABS_API_KEY) {
@ -540,13 +560,23 @@ router.post('/config/tts/test', async function(req, res) {
buffer = await synthesizeWithGoogleTTS(text, usedVoice);
} else if (provider === 'litellm') {
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
var rawModel = process.env.LITELLM_TTS_MODEL || 'tts-1';
var ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') ? rawModel : 'openai/' + rawModel;
usedVoice = voice || process.env.LITELLM_TTS_VOICE || 'alloy';
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
var db = require('../db/database');
var adminModel = await db.getSetting('tts.model') || '';
var adminVoice = await db.getSetting('tts.voice') || '';
if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; }
var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'];
var resolvedVoice = voice || adminVoice || '';
if (!adminModel && resolvedVoice) {
if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts';
else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3';
}
var rawModel = adminModel || process.env.LITELLM_TTS_MODEL || 'tts-1';
var ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') || rawModel.startsWith('vertex-') ? rawModel : 'openai/' + rawModel;
usedVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || 'alloy';
// base URL handled by gatewayUrl() helper
var ttsHeaders = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
var ttsResp = await axios.post(base + '/v1/audio/speech',
var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
{ model: ttsModel, voice: usedVoice, input: text },
{ headers: ttsHeaders, responseType: 'arraybuffer', timeout: 60000 }
);
@ -665,20 +695,20 @@ router.get('/config/stt/discover', async function(req, res) {
}
if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
// base URL handled by gatewayUrl() helper
var sHeaders = {};
if (process.env.LITELLM_API_KEY) sHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
try {
var sResp = await axios.get(base + '/v1/models', { headers: sHeaders, timeout: 10000 });
var sResp = await axios.get(gatewayUrl('/models'), { headers: sHeaders, timeout: 10000 });
if (sResp.data && sResp.data.data) {
sResp.data.data.forEach(function(m) {
var mid = (m.id || '').toLowerCase();
if (mid.indexOf('whisper') !== -1 || mid.indexOf('gemini') !== -1 || mid.indexOf('audio') !== -1 || mid.indexOf('transcri') !== -1) {
discovered.push({ id: m.id, name: m.id, source: 'litellm-api' });
if (mid.indexOf('whisper') !== -1 || mid.indexOf('gemini') !== -1 || mid.indexOf('audio') !== -1 || mid.indexOf('transcri') !== -1 || mid.indexOf('scribe') !== -1 || mid.indexOf('nova') !== -1 || mid.indexOf('deepgram') !== -1 || mid.indexOf('eleven') !== -1 || mid.indexOf('chirp') !== -1) {
discovered.push({ id: m.id, name: m.id, source: 'gateway-api' });
}
});
}
} catch(e) { logger.warn('LiteLLM STT discovery failed: ' + e.message); }
} catch(e) { logger.warn('STT discovery failed: ' + e.message); }
}
if (provider === 'local') {
@ -735,19 +765,38 @@ router.post('/config/stt/test', async function(req, res) {
text = await transcribeWithLocal(audioBuffer, mime);
} else if (provider === 'litellm') {
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
var sttModel = process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
var headers = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
var sttResp = await axios.post(base + '/v1/chat/completions', {
model: sttModel,
messages: [{ role: 'user', content: [
{ type: 'input_audio', input_audio: { data: audioBase64, format: mime.split('/')[1] || 'webm' } },
{ type: 'text', text: 'Transcribe this audio. Output the spoken words only.' }
]}]
}, { headers: headers, timeout: 60000 });
if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) {
text = (sttResp.data.choices[0].message && sttResp.data.choices[0].message.content) || '';
var db = require('../db/database');
var adminSttModel = await db.getSetting('stt.model') || '';
var sttModel = adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
// base URL handled by gatewayUrl() helper
var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel);
if (isTranscriptionModel) {
var ext = mime.split('/')[1] || 'webm';
var file = new File([audioBuffer], 'audio.' + ext, { type: mime });
var form = new FormData();
form.append('file', file);
form.append('model', sttModel);
var fetchHeaders = {};
if (process.env.LITELLM_API_KEY) fetchHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
var sttResp = await fetch(gatewayUrl('/audio/transcriptions'), {
method: 'POST', headers: fetchHeaders, body: form
}).then(function(r) { return r.json().then(function(d) { return { data: d }; }); });
text = sttResp.data && sttResp.data.text ? sttResp.data.text : '';
} else {
var headers = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
var sttResp = await axios.post(gatewayUrl('/chat/completions'), {
model: sttModel,
messages: [{ role: 'user', content: [
{ type: 'input_audio', input_audio: { data: audioBase64, format: mime.split('/')[1] || 'webm' } },
{ type: 'text', text: 'Transcribe this audio. Output the spoken words only.' }
]}]
}, { headers: headers, timeout: 60000 });
if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) {
text = (sttResp.data.choices[0].message && sttResp.data.choices[0].message.content) || '';
}
}
} else if (provider === 'openai') {
if (!whisperClient) return res.json({ success: false, error: 'Whisper not configured' });
@ -821,16 +870,16 @@ router.get('/config/embeddings/discover', async function(req, res) {
];
if (provider === 'litellm') {
var eBase = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
// base URL handled by gatewayUrl() helper
var eHeaders = {};
if (process.env.LITELLM_API_KEY) eHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
try {
var eResp = await axios.get(eBase + '/v1/models', { headers: eHeaders, timeout: 10000 });
var eResp = await axios.get(gatewayUrl('/models'), { headers: eHeaders, timeout: 10000 });
if (eResp.data && eResp.data.data) {
eResp.data.data.forEach(function(m) {
if ((m.id || '').toLowerCase().indexOf('embed') !== -1) {
var dims = m.id.indexOf('3-large') !== -1 ? 3072 : m.id.indexOf('3-small') !== -1 ? 1536 : m.id.indexOf('005') !== -1 ? 768 : '?';
discovered.push({ id: m.id, name: m.id, dims: dims, source: 'litellm-api' });
discovered.push({ id: m.id, name: m.id, dims: dims, source: 'gateway-api' });
}
});
}
@ -880,6 +929,8 @@ router.post('/config/embeddings/test', async function(req, res) {
try {
var text = (req.body.text || 'Pediatric patient with fever').substring(0, 500);
var { generateEmbedding, DEFAULT_MODEL } = require('../utils/embeddings');
var db = require('../db/database');
var dbModel = await db.getSetting('embeddings.model') || '';
var start = Date.now();
var vector = await generateEmbedding(text);
var dims = Array.isArray(vector) ? vector.length : 0;
@ -888,7 +939,7 @@ router.post('/config/embeddings/test', async function(req, res) {
success: true,
dimensions: dims,
sample: sample,
model: process.env.EMBEDDING_MODEL || DEFAULT_MODEL,
model: dbModel || process.env.EMBEDDING_MODEL || DEFAULT_MODEL,
duration: Date.now() - start
});
} catch (e) {

View file

@ -10,6 +10,7 @@ var path = require('path');
var { callAI } = require('../utils/ai');
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
var db = require('../db/database');
var cryptoUtil = require('../utils/crypto');
router.use(authMiddleware);
router.use(moderatorMiddleware);
@ -28,12 +29,16 @@ var upload = multer({
'text/markdown',
'text/html',
'text/csv',
'application/json'
'application/json',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.text',
'application/epub+zip'
];
if (allowed.includes(file.mimetype) || file.originalname.match(/\.(pdf|txt|md|html|htm|csv|json)$/i)) {
if (allowed.includes(file.mimetype) || file.originalname.match(/\.(pdf|txt|md|html|htm|csv|json|docx|pptx|odt|epub)$/i)) {
cb(null, true);
} else {
cb(new Error('File type not allowed. Only PDF, TXT, MD, HTML, CSV, JSON are supported.'));
cb(new Error('File type not allowed. Supported: PDF, DOCX, PPTX, ODT, EPUB, TXT, MD, HTML, CSV, JSON.'));
}
}
});
@ -64,6 +69,65 @@ async function extractText(buffer, mimetype, filename) {
return buffer.toString('utf8');
}
// DOCX
if (mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || ext === 'docx') {
try {
var mammoth = require('mammoth');
var result = await mammoth.extractRawText({ buffer: buffer });
return result.value || '';
} catch (e) {
throw new Error('Could not parse DOCX: ' + e.message);
}
}
// PPTX — extract text from slide XML inside the zip
if (mimetype === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' || ext === 'pptx') {
try {
var JSZip = require('jszip');
var zip = await JSZip.loadAsync(buffer);
var slides = Object.keys(zip.files).filter(function(f) { return /^ppt\/slides\/slide\d+\.xml$/.test(f); }).sort();
var texts = [];
for (var i = 0; i < slides.length; i++) {
var xml = await zip.files[slides[i]].async('string');
var slideText = xml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (slideText) texts.push('--- Slide ' + (i + 1) + ' ---\n' + slideText);
}
return texts.join('\n\n');
} catch (e) {
throw new Error('Could not parse PPTX: ' + e.message);
}
}
// ODT — extract text from content.xml inside the zip
if (mimetype === 'application/vnd.oasis.opendocument.text' || ext === 'odt') {
try {
var JSZip = require('jszip');
var zip = await JSZip.loadAsync(buffer);
var contentXml = await zip.files['content.xml'].async('string');
return contentXml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
} catch (e) {
throw new Error('Could not parse ODT: ' + e.message);
}
}
// EPUB — extract text from XHTML chapters inside the zip
if (mimetype === 'application/epub+zip' || ext === 'epub') {
try {
var JSZip = require('jszip');
var zip = await JSZip.loadAsync(buffer);
var chapters = Object.keys(zip.files).filter(function(f) { return /\.(xhtml|html|htm)$/i.test(f) && !zip.files[f].dir; }).sort();
var texts = [];
for (var i = 0; i < chapters.length; i++) {
var html = await zip.files[chapters[i]].async('string');
var chapterText = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (chapterText) texts.push(chapterText);
}
return texts.join('\n\n');
} catch (e) {
throw new Error('Could not parse EPUB: ' + e.message);
}
}
// Fallback — try utf8
return buffer.toString('utf8');
}
@ -215,9 +279,12 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res)
if (!user || !user.nextcloud_url) {
return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings first.' });
}
var ncPassword;
try { ncPassword = cryptoUtil.decryptString(user.nextcloud_token); }
catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); }
var fileUrl = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + webdavPath;
var response = await axios.get(fileUrl, {
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
auth: { username: user.nextcloud_user, password: ncPassword },
responseType: 'arraybuffer',
timeout: 30000
});
@ -372,6 +439,10 @@ router.get('/webdav-browse', async function(req, res) {
return res.status(400).json({ error: 'Nextcloud not connected' });
}
var ncPassword;
try { ncPassword = cryptoUtil.decryptString(user.nextcloud_token); }
catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); }
var browsePath = req.query.path || user.webdav_learning_path || user.nextcloud_folder || '/';
// Ensure it starts with /
if (!browsePath.startsWith('/')) browsePath = '/' + browsePath;
@ -381,7 +452,7 @@ router.get('/webdav-browse', async function(req, res) {
var davResponse = await axios({
method: 'PROPFIND',
url: davUrl,
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
auth: { username: user.nextcloud_user, password: ncPassword },
headers: { Depth: '1', 'Content-Type': 'application/xml' },
data: `<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:getcontenttype/><d:getcontentlength/><d:resourcetype/></d:prop></d:propfind>`,
timeout: 15000

View file

@ -7,6 +7,7 @@ const { transcribeWithLocal, isLocalWhisperConfigured } = require('../utils/tran
const { transcribeWithGemini, isGoogleSTTConfigured } = require('../utils/transcribeGoogle');
const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
@ -92,7 +93,6 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r
if (!process.env.LITELLM_API_BASE) return res.status(400).json({ error: 'LITELLM_API_BASE not set.' });
var sttModel = userModel || adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
var axios = require('axios');
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
var mimeType = req.file.mimetype || 'audio/webm';
// Models that use /audio/transcriptions (Whisper, Deepgram, Groq Whisper, etc.)
@ -108,7 +108,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r
var fetchHeaders = {};
if (process.env.LITELLM_API_KEY) fetchHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
var sttResp = await fetch(base + '/v1/audio/transcriptions', {
var sttResp = await fetch(gatewayUrl('/audio/transcriptions'), {
method: 'POST', headers: fetchHeaders, body: form
}).then(function(r) { return r.json().then(function(d) { return { data: d }; }); });
var text = sttResp.data && sttResp.data.text ? sttResp.data.text : '';
@ -122,7 +122,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r
var headers = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
var sttResp = await axios.post(base + '/v1/chat/completions', {
var sttResp = await axios.post(gatewayUrl('/chat/completions'), {
model: sttModel,
messages: [{
role: 'user',

View file

@ -4,6 +4,7 @@ const axios = require('axios');
const { synthesizeWithGoogleTTS, isGoogleTTSConfigured } = require('../utils/ttsGoogle');
const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors');
// Provider priority (auto-detect):
// TTS_PROVIDER=google → Google Cloud TTS direct (HIPAA eligible)
@ -38,9 +39,20 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
var userVoice = userPrefs?.tts_voice;
var adminVoice = await db.getSetting('tts.voice') || '';
var adminModel = await db.getSetting('tts.model') || '';
// tts.voice may contain a model ID (from discovery "Set" button) — detect and use as model
if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; }
// Auto-detect model from voice name when no explicit model is set
var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'];
var OPENAI_VOICES = ['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral'];
var resolvedVoice = userVoice || adminVoice || '';
if (!adminModel && resolvedVoice) {
if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts';
else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3'; // ElevenLabs voice IDs are long alphanumeric
}
if (ttsProvider === 'google') {
var voice = userVoice || adminVoice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F';
var voice = resolvedVoice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F';
var buffer = await synthesizeWithGoogleTTS(text, voice);
res.set('Content-Type', 'audio/mpeg');
res.set('X-TTS-Provider', 'google-tts/' + voice);
@ -50,16 +62,12 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
if (ttsProvider === 'litellm') {
if (!process.env.LITELLM_API_BASE) return res.status(400).json({ error: 'LITELLM_API_BASE not set.' });
// Use the full model path (e.g. vertex_ai/google-tts) or the model_name alias
// from your LiteLLM model_list. Full path is safer if your config uses it directly.
// Use model name as-is if set explicitly, else prefix with openai/ for generic names like 'tts-1'
var rawModel = adminModel || process.env.LITELLM_TTS_MODEL || 'tts-1';
var ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') ? rawModel : 'openai/' + rawModel;
var ttsVoice = userVoice || adminVoice || process.env.LITELLM_TTS_VOICE || 'alloy';
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
var ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') || rawModel.startsWith('vertex-') ? rawModel : 'openai/' + rawModel;
var ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || 'alloy';
var ttsHeaders = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
var ttsResp = await axios.post(base + '/v1/audio/speech',
var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
{ model: ttsModel, voice: ttsVoice, input: text },
{ headers: ttsHeaders, responseType: 'arraybuffer', timeout: 60000 }
);

View file

@ -410,7 +410,8 @@ async function callAI(messages, options) {
// Prevents a client from passing e.g. model:"openai/o1" and draining
// the budget on a reasoning model outside the configured roster.
// Falls back silently to DEFAULT_MODEL when no model was provided.
if (requestedModel) {
// Admin test endpoints pass skipAllowlistCheck to test before adding.
if (requestedModel && !options.skipAllowlistCheck) {
try {
var db = require('../db/database');
var { getAllowedModelIds } = require('./models');

View file

@ -12,4 +12,17 @@ function serverError(res, scope, err, publicMessage) {
return res.status(500).json({ error: publicMessage || 'Request failed. Please try again.' });
}
module.exports = { serverError: serverError };
// ============================================================
// AI gateway URL helper — builds endpoint URLs from
// LITELLM_API_BASE, handling both with and without /v1 suffix.
// Usage: gatewayUrl('/audio/speech') → 'https://gw.example.com/v1/audio/speech'
// The OpenAI SDK (ai.js) uses LITELLM_API_BASE directly as
// baseURL and must NOT use this helper.
// ============================================================
function gatewayUrl(path) {
var base = (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
return base + '/v1' + (path.startsWith('/') ? path : '/' + path);
}
module.exports = { serverError: serverError, gatewayUrl: gatewayUrl };