"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
199 lines
8.3 KiB
JavaScript
199 lines
8.3 KiB
JavaScript
const { getLiteLLMHeaders } = require('./litellm');
|
|
|
|
function parseList(value) {
|
|
return String(value || '')
|
|
.split(',')
|
|
.map(function(item) { return item.trim(); })
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// Kitten and Supertonic were retired from the gateway in favour of Kokoro, so
|
|
// their voice lists went with them. Voices are curated per family rather than
|
|
// discovered: no TTS provider exposes its voices consistently, and a list
|
|
// guessed from a model id is how a picker ends up offering a voice the provider
|
|
// will reject.
|
|
var GROQ_ORPHEUS_ENGLISH_VOICES = ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy'];
|
|
var GROQ_ORPHEUS_ARABIC_VOICES = ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha'];
|
|
|
|
// ── Which voices belong to which model ───────────────────────────────────
|
|
// A voice is not a property of the gateway, it is a property of the model, and
|
|
// the two were being listed side by side as if interchangeable. Picking Orpheus
|
|
// and testing it with a Kokoro voice is not a configuration, it is an error —
|
|
// every one of those calls came back 500, which is what "these settings don't
|
|
// work" was.
|
|
//
|
|
// The gateway cannot help: /model/info reports mode audio_speech for all four
|
|
// of these and carries no voice field at all. So the mapping lives here.
|
|
//
|
|
// Every list below was taken from the provider rather than from documentation.
|
|
// Groq states its own when refused: "voice must be one of the following
|
|
// voices: [...]". Fish was found by trying the OpenAI names — alloy returns
|
|
// audio and the rest are refused. Kokoro's come from the speech gateway's own
|
|
// /v1/audio/voices, which is authoritative and is preferred over this list
|
|
// whenever it answers.
|
|
// Keyed by family, not by id: getLiteLLMTTSModelFamily already maps both the
|
|
// gateway alias (groq-orpheus-english) and the upstream id
|
|
// (canopylabs/orpheus-v1-english) to one family, and a table keyed by id would
|
|
// have to repeat every list under every spelling.
|
|
var MODEL_VOICES = Object.freeze({
|
|
'groq-orpheus-english': Object.freeze(GROQ_ORPHEUS_ENGLISH_VOICES.slice()),
|
|
'groq-orpheus-arabic': Object.freeze(GROQ_ORPHEUS_ARABIC_VOICES.slice()),
|
|
'fish': Object.freeze(['alloy']),
|
|
'kokoro': Object.freeze([
|
|
'sherpa/kokoro:am_adam', 'sherpa/kokoro:am_michael', 'sherpa/kokoro:af_bella',
|
|
'sherpa/kokoro:af_nicole', 'sherpa/kokoro:bf_emma', 'sherpa/kokoro:bm_lewis'
|
|
])
|
|
});
|
|
|
|
/**
|
|
* The voices a model will actually accept, or [] when we do not know.
|
|
*
|
|
* [] is an honest answer and a useful one: the screen can say "no voice list
|
|
* for this model, type one" instead of offering another model's voices, which
|
|
* is the thing that was broken.
|
|
*/
|
|
function voicesForModel(model) {
|
|
var id = String(model || '');
|
|
// The environment wins for the model it was written for. LITELLM_TTS_VOICES
|
|
// names the voices of LITELLM_TTS_MODEL, so a deployment can change that
|
|
// model's voices without changing this file — which matters for the local
|
|
// gateway, whose list is whatever it has been built with. It answers for
|
|
// that model only: it never described any other, and treating it as a
|
|
// general list is what offered Kokoro's voices for Fish.
|
|
if (id && id === String(process.env.LITELLM_TTS_MODEL || '')) {
|
|
var configured = parseList(process.env.LITELLM_TTS_VOICES);
|
|
if (configured.length) return configured;
|
|
}
|
|
var known = MODEL_VOICES[getLiteLLMTTSModelFamily(id)];
|
|
return known ? known.slice() : [];
|
|
}
|
|
|
|
function uniqueList(values) {
|
|
var seen = new Set();
|
|
return (values || []).filter(function(value) {
|
|
if (!value || seen.has(value)) return false;
|
|
seen.add(value);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function getTTSProvider() {
|
|
var env = process.env.TTS_PROVIDER;
|
|
if (env === 'litellm') return 'litellm';
|
|
if (process.env.LITELLM_API_BASE) return 'litellm';
|
|
return 'none';
|
|
}
|
|
|
|
function getTTSEnvProvider() {
|
|
return process.env.TTS_PROVIDER || 'auto';
|
|
}
|
|
|
|
function getTTSVoiceLists() {
|
|
return {
|
|
litellm: uniqueList(parseList(process.env.LITELLM_TTS_VOICES).concat(GROQ_ORPHEUS_ENGLISH_VOICES, GROQ_ORPHEUS_ARABIC_VOICES))
|
|
};
|
|
}
|
|
|
|
function getLiteLLMTTSModelFamily(model) {
|
|
var id = String(model || '').toLowerCase();
|
|
if (id === 'local-kokoro-tts') return 'kokoro';
|
|
if (id === 'groq-orpheus-english' || id === 'canopylabs/orpheus-v1-english') return 'groq-orpheus-english';
|
|
if (id === 'groq-orpheus-arabic-saudi' || id === 'canopylabs/orpheus-arabic-saudi') return 'groq-orpheus-arabic';
|
|
if (id === 'openrouter-fish-s2.1-pro-tts' || id === 'fish-audio/s2.1-pro') return 'fish';
|
|
return 'unknown';
|
|
}
|
|
|
|
function getLiteLLMTTSRequestOptions(model) {
|
|
var family = getLiteLLMTTSModelFamily(model);
|
|
if (family === 'groq-orpheus-english' || family === 'groq-orpheus-arabic') {
|
|
return { response_format: 'wav' };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function isLiteLLMTTSVoiceCompatible(model, voice) {
|
|
if (typeof voice !== 'string' || !voice.trim()) return false;
|
|
var known = voicesForModel(model);
|
|
// A model we have a list for accepts what is on it and nothing else — that
|
|
// refusal is the whole point, and it is what stops a Kokoro voice being
|
|
// offered for Orpheus.
|
|
if (known.length) return known.indexOf(String(voice)) !== -1 || known.indexOf(String(voice).toLowerCase()) !== -1;
|
|
// A model we have no list for: anything is allowed, because refusing would
|
|
// mean refusing every voice of a model added after this code was written.
|
|
// Another model's voice is still refused — those we do know to be wrong.
|
|
var family = getLiteLLMTTSModelFamily(model);
|
|
return !Object.keys(MODEL_VOICES).some(function (other) {
|
|
return other !== family && MODEL_VOICES[other].indexOf(String(voice).toLowerCase()) !== -1;
|
|
});
|
|
}
|
|
|
|
function getLiteLLMTTSVoicesForModel(model, opts) {
|
|
opts = opts || {};
|
|
// One table, MODEL_VOICES, rather than a second copy of the same knowledge.
|
|
// The old branch fell through to LITELLM_TTS_VOICES for any model it did not
|
|
// recognise, so an unknown model was offered Kokoro's voices — which is how
|
|
// Fish came to be listed with six voices it refuses.
|
|
var voices = voicesForModel(model);
|
|
|
|
[opts.currentVoice, process.env.LITELLM_TTS_VOICE].forEach(function(voice) {
|
|
if (isLiteLLMTTSVoiceCompatible(model, voice)) voices.push(voice);
|
|
});
|
|
return uniqueList(voices.filter(function(voice) { return isLiteLLMTTSVoiceCompatible(model, voice); }));
|
|
}
|
|
|
|
function isLiteLLMTTSModel(model) {
|
|
var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : '';
|
|
return mode === 'audio_speech';
|
|
}
|
|
|
|
function getLiteLLMTTSModels(models) {
|
|
return (models || [])
|
|
.filter(isLiteLLMTTSModel)
|
|
.map(function(model) { return model && (model.id || model.model_name) ? (model.id || model.model_name) : String(model || ''); });
|
|
}
|
|
|
|
function pushUniqueTTSItem(items, item) {
|
|
if (!item || !item.id) return;
|
|
if (items.some(function(existing) { return existing.id === item.id && existing.kind === item.kind; })) return;
|
|
items.push(item);
|
|
}
|
|
|
|
function getLiteLLMTTSDiscoveryItems(models, opts) {
|
|
opts = opts || {};
|
|
var items = [];
|
|
getLiteLLMTTSModels(models).forEach(function(id) {
|
|
pushUniqueTTSItem(items, { id: id, name: id, source: 'gateway-api', kind: 'model' });
|
|
});
|
|
if (opts.currentModel) {
|
|
pushUniqueTTSItem(items, { id: opts.currentModel, name: opts.currentModel, source: 'configured-model', kind: 'model' });
|
|
}
|
|
if (opts.currentVoice) {
|
|
pushUniqueTTSItem(items, { id: opts.currentVoice, name: opts.currentVoice, source: 'configured-voice', kind: 'voice' });
|
|
}
|
|
// Every voice now says which model it belongs to. They used to be pushed
|
|
// into one flat list, so the screen offered twelve Orpheus voices and six
|
|
// Kokoro ones together with no way to tell which model would accept which.
|
|
var offered = getLiteLLMTTSModels(models).slice();
|
|
if (opts.currentModel && offered.indexOf(opts.currentModel) === -1) offered.push(opts.currentModel);
|
|
offered.forEach(function(model) {
|
|
voicesForModel(model).forEach(function(voice) {
|
|
pushUniqueTTSItem(items, { id: voice, name: voice, source: model, kind: 'voice', model: model });
|
|
});
|
|
});
|
|
return items;
|
|
}
|
|
|
|
module.exports = {
|
|
voicesForModel,
|
|
MODEL_VOICES,
|
|
getTTSEnvProvider,
|
|
getLiteLLMTTSDiscoveryItems,
|
|
getLiteLLMHeaders,
|
|
getLiteLLMTTSModels,
|
|
getLiteLLMTTSRequestOptions,
|
|
getLiteLLMTTSVoicesForModel,
|
|
getTTSProvider,
|
|
getTTSVoiceLists,
|
|
isLiteLLMTTSVoiceCompatible,
|
|
isLiteLLMTTSModel
|
|
};
|