pediatric-ai-scribe-v3/test/tts-provider.test.js
Daniel e376f69502
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 21s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
fix: each TTS model offers only the voices it will accept
"These settings don't work" — picking a model and testing with a voice
returned 500 every time. The gateway said why, once asked directly:

    voice must be one of the following voices: [autumn diana hannah austin daniel troy]

The screen was listing twelve Orpheus voices and six Kokoro ones in a
single flat list with no indication of which model would accept which,
because LITELLM_TTS_VOICES — written for one model — was treated as a
list that applied to all of them, and the Orpheus lists were pushed in
beside it. Choosing Orpheus and testing it with a Kokoro voice is not a
configuration; it is an error, and it was the default outcome.

LiteLLM cannot supply this. /model/info reports mode audio_speech for
all four models and carries no voice field for any of them. So the
mapping lives here, keyed by family so the gateway alias and the
upstream id resolve to one list, and every list was taken from the
provider rather than from documentation:

  Groq Orpheus English   autumn diana hannah austin daniel troy   (stated by Groq)
  Groq Orpheus Arabic    abdullah fahad sultan lulwa noura aisha  (stated by Groq)
  Fish s2.1-pro          alloy                                    (alloy returns audio; the rest 400)
  Kokoro                 sherpa/kokoro:* from LITELLM_TTS_VOICES  (the gateway's own list)

The environment still wins for the model it was written for, so the
local gateway's voices can change without a code change — but it
answers for that model only. A model with no list at all is offered
nothing rather than another model's voices, and a voice known to belong
to a different family is refused.

There were two copies of this knowledge before: getLiteLLMTTSVoicesForModel
branched by family and fell through to the env list for any model it did
not recognise — which is how Fish came to be offered six Kokoro voices.
One table now.

Also in this commit: citation renumbering skips fenced code, inline code
and math, so arr[2][1] in a code block is never rewritten. Renumbering at
render time was tried and reverted — it also has to skip HTML attributes,
and every such region is another regex branch. It stays at the answer
boundary, and the saved-chat boundary is next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 03:54:31 +02:00

179 lines
9.9 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert/strict');
const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES', 'LITELLM_TTS_VOICE'];
function withEnv(overrides, fn) {
const previous = {};
ENV_KEYS.forEach((key) => { previous[key] = process.env[key]; delete process.env[key]; });
Object.entries(overrides).forEach(([key, value]) => { process.env[key] = value; });
try {
return fn();
} finally {
ENV_KEYS.forEach((key) => {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
});
}
}
test('TTS provider defaults to LiteLLM when gateway is configured', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_API_BASE: 'https://llm.example.com' }, () => {
assert.equal(ttsProvider.getTTSEnvProvider(), 'auto');
assert.equal(ttsProvider.getTTSProvider(), 'litellm');
});
});
test('TTS provider ignores non-LiteLLM provider overrides', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ TTS_PROVIDER: 'some-other-provider', LITELLM_API_BASE: 'https://llm.example.com' }, () => {
assert.equal(ttsProvider.getTTSEnvProvider(), 'some-other-provider');
assert.equal(ttsProvider.getTTSProvider(), 'litellm');
});
});
test('LiteLLM TTS voice list comes from configured voice catalog', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam, sherpa/kokoro:af_bella' }, () => {
const voices = ttsProvider.getTTSVoiceLists().litellm;
assert.equal(voices[0], 'sherpa/kokoro:am_adam');
assert.equal(voices[1], 'sherpa/kokoro:af_bella');
// The configured list, plus the curated voices of the families that remain.
// Kitten and Supertonic were retired with their models.
assert.equal(voices.includes('hannah'), true, 'Orpheus English');
assert.equal(voices.includes('noura'), true, 'Orpheus Arabic');
assert.equal(voices.includes('Jasper'), false, 'Kitten is gone');
assert.equal(voices.includes('F1'), false, 'Supertonic is gone');
});
});
test('TTS model discovery uses audio_speech metadata only', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.equal(ttsProvider.isOpenAITTSModel, undefined);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-chatterbox-turbo' }), false);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-kokoro-tts' }), false);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'openai-tts-1' }), false);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'openai-gpt-4o-mini-tts' }), false);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'mistral-voxtral-mini-tts' }), false);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'vertex-gemini-2.5-flash-tts' }), false);
assert.equal(ttsProvider.isLiteLLMTTSModel({ model_name: 'local-chatterbox-turbo', model_info: { mode: 'audio_speech' } }), true);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'custom-provider-model', model_info: { mode: 'audio_speech' } }), true);
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } }), false);
});
test('LiteLLM TTS model extraction filters gateway model objects', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSModels([
{ id: 'openai-tts-1' },
{ id: 'mistral-voxtral-mini-tts' },
{ id: 'openai-gpt-4o-mini-tts' },
{ id: 'local-chatterbox-turbo' },
{ model_name: 'local-chatterbox-turbo', model_info: { mode: 'audio_speech' } },
{ id: 'custom-provider-model', model_info: { mode: 'audio_speech' } },
{ id: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } },
{ id: 'vertex-gemini-2.5-flash-tts' },
{ id: 'gpt-4.1' }
]), ['local-chatterbox-turbo', 'custom-provider-model']);
});
test('LiteLLM TTS discovery includes metadata models and configured fallbacks', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella' }, () => {
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
{ model_name: 'local-kokoro-tts', model_info: { mode: 'audio_speech' } },
{ model_name: 'not-tts-by-name-only' },
{ model_name: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } }
], {
currentModel: 'local-kokoro-tts',
currentVoice: 'sherpa/kokoro:am_adam'
});
assert.deepEqual(items.slice(0, 3), [
{ id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'gateway-api', kind: 'model' },
{ id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' },
// A voice now says which model it belongs to. It used to say
// "configured-voice-list", which named the file it came from and not the
// model that would accept it — the distinction the screen was missing.
{ id: 'sherpa/kokoro:af_bella', name: 'sherpa/kokoro:af_bella', source: 'local-kokoro-tts', kind: 'voice', model: 'local-kokoro-tts' }
]);
assert.equal(items.some(function(item) { return item.id === 'not-tts-by-name-only'; }), false);
});
});
test('LiteLLM TTS discovery expands the voices of a model it can see', () => {
// Models are discovered from /model/info; voices are not. No TTS provider
// exposes its voice list consistently, so each family's voices are curated
// here — a list guessed from a model id is how a picker ends up offering a
// voice the provider rejects. Kitten and Supertonic were retired from the
// gateway in favour of Kokoro, and their lists went with them.
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({}, () => {
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
{ model_name: 'groq-orpheus-english', model_info: { mode: 'audio_speech' } }
], {});
assert.equal(items.some(function(item) { return item.id === 'groq-orpheus-english' && item.kind === 'model'; }), true);
assert.equal(items.some(function(item) { return item.id === 'hannah' && item.kind === 'voice'; }), true);
});
});
test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
currentModel: 'local-kokoro-tts',
currentVoice: 'sherpa/kokoro:am_adam'
}).slice(0, 2), [
{ id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'configured-model', kind: 'model' },
{ id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' }
]);
});
test('LiteLLM TTS voices are scoped to the active local model', () => {
const ttsProvider = require('../src/utils/ttsProvider');
// LITELLM_TTS_VOICES names the voices of LITELLM_TTS_MODEL and no other
// model — production sets both. Treating that list as universal is what
// offered Kokoro's voices for Fish.
withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella', LITELLM_TTS_VOICE: 'sherpa/kokoro:am_adam' }, () => {
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kokoro-tts'), ['sherpa/kokoro:am_adam', 'sherpa/kokoro:af_bella']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('groq-orpheus-english'), ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('canopylabs/orpheus-arabic-saudi'), ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha']);
});
});
test('LiteLLM TTS compatibility rejects cross-model local voices', () => {
const ttsProvider = require('../src/utils/ttsProvider');
// Kokoro names its own voices and the list is open, so it accepts anything
// that is not another family's voice. An Orpheus voice is still refused.
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'sherpa/kokoro:af_bella'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'hannah'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'noura'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'hannah'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'hannah'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'sherpa/kokoro:af_bella'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'aisha'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'troy'), false);
});
test('Groq Orpheus TTS requests force wav response format', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('groq-orpheus-english'), { response_format: 'wav' });
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('canopylabs/orpheus-arabic-saudi'), { response_format: 'wav' });
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('local-kokoro-tts'), {});
});
test('LiteLLM headers include optional content type without exposing key value', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_API_KEY: 'secret-token' }, () => {
assert.deepEqual(ttsProvider.getLiteLLMHeaders('application/json'), {
'Content-Type': 'application/json',
Authorization: 'Bearer secret-token'
});
});
});
test('LiteLLM admin headers prefer master key for metadata routes', () => {
const { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../src/utils/litellm');
withEnv({ LITELLM_API_KEY: 'virtual-token', LITELLM_MASTER_KEY: 'master-token' }, () => {
assert.equal(getLiteLLMHeaders().Authorization, 'Bearer virtual-token');
assert.equal(getLiteLLMAdminHeaders().Authorization, 'Bearer master-token');
});
});