Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m12s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Both were removed from the gateway in favour of Kokoro, so the app's curated voice lists for them went too. Kokoro's own check no longer has to exclude them — it names its own voices and the list is open, so it accepts anything that is not another family's voice. Voices stay curated per family rather than discovered. Models are discovered from /model/info; voices are not, because no TTS provider exposes its voice list consistently, and a list guessed from a model id is how a picker ends up offering a voice the provider rejects. Tests that pinned the retired voices now pin the families that remain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
173 lines
9.4 KiB
JavaScript
173 lines
9.4 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_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' },
|
|
{ id: 'sherpa/kokoro:af_bella', name: 'sherpa/kokoro:af_bella', source: 'configured-voice-list', kind: 'voice' }
|
|
]);
|
|
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');
|
|
withEnv({ 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');
|
|
});
|
|
});
|