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
This commit is contained in:
parent
3e8972348a
commit
e376f69502
4 changed files with 134 additions and 31 deletions
|
|
@ -119,6 +119,15 @@ function sourceByNumber(sources, n) {
|
|||
* Returns new objects. Renumbering in place would corrupt a stored answer whose
|
||||
* text still holds the original markers.
|
||||
*/
|
||||
// Fenced code, inline code and math, then a citation cluster. Alternation, so
|
||||
// a cluster inside one of those regions matches the protecting branch first and
|
||||
// is never rewritten — the same trick renderCitationLinks uses on HTML.
|
||||
//
|
||||
// This is not cosmetic. An answer containing arr[2][1] in a code block would
|
||||
// otherwise have its indices rewritten to whatever the source mapping said, and
|
||||
// nothing downstream could tell that the code had been altered.
|
||||
var CITATION_SCAN = /```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`|\$\$[\s\S]*?\$\$|\$[^$\n]*\$|\[((?:\d+\s*,\s*)*\d+)\]/g;
|
||||
|
||||
export function orderSourcesByCitation(text, sources) {
|
||||
var list = Array.isArray(sources) ? sources : [];
|
||||
if (!list.length) return { text: String(text || ''), sources: list };
|
||||
|
|
@ -126,7 +135,8 @@ export function orderSourcesByCitation(text, sources) {
|
|||
// First appearance wins, and only markers that resolve to a real source
|
||||
// count — an invented number must not reserve a position in the list.
|
||||
var order = [];
|
||||
String(text || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (_, cluster) {
|
||||
String(text || '').replace(CITATION_SCAN, function (match, cluster) {
|
||||
if (!cluster) return match; // a protected region
|
||||
cluster.split(',').forEach(function (part) {
|
||||
var n = Number(part.trim());
|
||||
if (!Number.isInteger(n) || n < 1) return;
|
||||
|
|
@ -152,7 +162,8 @@ export function orderSourcesByCitation(text, sources) {
|
|||
|
||||
// Rewrite the markers in one pass. Doing it number by number would renumber
|
||||
// something twice — 2 becomes 1, then that 1 becomes whatever 1 maps to.
|
||||
var rewritten = String(text || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) {
|
||||
var rewritten = String(text || '').replace(CITATION_SCAN, function (match, cluster) {
|
||||
if (!cluster) return match; // a protected region
|
||||
var nums = cluster.split(',').map(function (p) { return Number(p.trim()); });
|
||||
if (nums.some(function (n) { return !mapping[n]; })) return match; // leave anything unresolved alone
|
||||
return '[' + nums.map(function (n) { return mapping[n]; }).join(', ') + ']';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,59 @@ function parseList(value) {
|
|||
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) {
|
||||
|
|
@ -46,6 +99,7 @@ function getLiteLLMTTSModelFamily(model) {
|
|||
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';
|
||||
}
|
||||
|
||||
|
|
@ -59,25 +113,27 @@ function getLiteLLMTTSRequestOptions(model) {
|
|||
|
||||
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);
|
||||
if (family === 'groq-orpheus-english') return GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) !== -1;
|
||||
if (family === 'groq-orpheus-arabic') return GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) !== -1;
|
||||
if (family === 'kokoro') {
|
||||
// Kokoro names its own voices (sherpa/kokoro:am_adam and the rest) and the
|
||||
// list is open, so anything that is not another family's voice is allowed.
|
||||
return GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) === -1 &&
|
||||
GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) === -1;
|
||||
}
|
||||
return true;
|
||||
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 || {};
|
||||
var family = getLiteLLMTTSModelFamily(model);
|
||||
var voices = [];
|
||||
if (family === 'groq-orpheus-english') voices = GROQ_ORPHEUS_ENGLISH_VOICES.slice();
|
||||
else if (family === 'groq-orpheus-arabic') voices = GROQ_ORPHEUS_ARABIC_VOICES.slice();
|
||||
else voices = parseList(process.env.LITELLM_TTS_VOICES);
|
||||
// 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);
|
||||
|
|
@ -114,23 +170,22 @@ function getLiteLLMTTSDiscoveryItems(models, opts) {
|
|||
if (opts.currentVoice) {
|
||||
pushUniqueTTSItem(items, { id: opts.currentVoice, name: opts.currentVoice, source: 'configured-voice', kind: 'voice' });
|
||||
}
|
||||
getTTSVoiceLists().litellm.forEach(function(voice) {
|
||||
pushUniqueTTSItem(items, { id: voice, name: voice, source: 'configured-voice-list', 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 });
|
||||
});
|
||||
});
|
||||
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-english') !== -1 || opts.currentModel === 'groq-orpheus-english') {
|
||||
GROQ_ORPHEUS_ENGLISH_VOICES.forEach(function(voice) {
|
||||
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus ' + voice, source: 'groq-orpheus-english', kind: 'voice' });
|
||||
});
|
||||
}
|
||||
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-arabic-saudi') !== -1 || opts.currentModel === 'groq-orpheus-arabic-saudi') {
|
||||
GROQ_ORPHEUS_ARABIC_VOICES.forEach(function(voice) {
|
||||
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus Arabic ' + voice, source: 'groq-orpheus-arabic-saudi', kind: 'voice' });
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
voicesForModel,
|
||||
MODEL_VOICES,
|
||||
getTTSEnvProvider,
|
||||
getLiteLLMTTSDiscoveryItems,
|
||||
getLiteLLMHeaders,
|
||||
|
|
|
|||
|
|
@ -109,3 +109,34 @@ test('sources with no number field fall back to position', () => {
|
|||
assert.equal(out.sources[0].title, 'Three');
|
||||
assert.equal(out.text, 'Cite the third [1].');
|
||||
});
|
||||
|
||||
// ---- what must never be touched ---------------------------------------------
|
||||
|
||||
test('a citation-shaped index inside code is not a citation', () => {
|
||||
// arr[2][1] is array indexing. Renumbering it would alter the code and
|
||||
// nothing downstream could tell.
|
||||
const { orderSourcesByCitation } = load();
|
||||
const text = 'Use [3] here.\n\n```js\nconst x = arr[2][1];\n```\n\nAnd `m[1][2]` inline.';
|
||||
const out = orderSourcesByCitation(text, four);
|
||||
assert.match(out.text, /Use \[1\] here/);
|
||||
assert.match(out.text, /arr\[2\]\[1\]/, 'the fenced code was rewritten');
|
||||
assert.match(out.text, /`m\[1\]\[2\]`/, 'the inline code was rewritten');
|
||||
});
|
||||
|
||||
test('a bracket inside math is not a citation', () => {
|
||||
const { orderSourcesByCitation } = load();
|
||||
const text = 'See [2].\n\n$$ f[1] = x $$ and $g[3]$.';
|
||||
const out = orderSourcesByCitation(text, four);
|
||||
assert.match(out.text, /See \[1\]/);
|
||||
assert.match(out.text, /\$\$ f\[1\] = x \$\$/);
|
||||
assert.match(out.text, /\$g\[3\]\$/);
|
||||
});
|
||||
|
||||
test('a bracket inside code reserves no place in the order either', () => {
|
||||
// If [1] inside a code block counted as a citation, the first real citation
|
||||
// would come out numbered 2.
|
||||
const { orderSourcesByCitation } = load();
|
||||
const out = orderSourcesByCitation('```\nx[1]\n```\n\nReal [4].', four);
|
||||
assert.equal(out.sources[0].title, 'Delta');
|
||||
assert.match(out.text, /Real \[1\]/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ test('LiteLLM TTS model extraction filters gateway model objects', () => {
|
|||
|
||||
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' }, () => {
|
||||
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' },
|
||||
|
|
@ -91,7 +91,10 @@ test('LiteLLM TTS discovery includes metadata models and configured fallbacks',
|
|||
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' }
|
||||
// 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);
|
||||
});
|
||||
|
|
@ -126,7 +129,10 @@ test('LiteLLM TTS discovery still shows configured model if metadata lookup fail
|
|||
|
||||
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' }, () => {
|
||||
// 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']);
|
||||
|
|
|
|||
Loading…
Reference in a new issue