fix(config): key the voice-cloning model rule on the resolved backend

Making GetBackendCapability strip the gallery hardware prefix and release
channel fixed pinned variants of /audio/transform, but VoiceCloningForModel
kept keying its per-backend switch on the caller's spelling. A pinned name
therefore resolved the capability by stripping and then missed every case in
the switch, falling through to the permissive default: cuda12-vibevoice-cpp
advertised voice cloning for the realtime 0.5B model, metal-coqui for
tacotron2, cuda12-crispasr for a pure ASR model, cpu-qwen3-tts-cpp for
CustomVoice. Each of those is a model that cannot clone, so /v1/audio/speech
accepted a profile: voice it had to fail on inside the backend rather than
rejecting it with a 400, and the UI advertised the capability too.

resolveBackendCapability now returns the key the entry was found under, and
callers that branch on backend identity use that key instead of the name they
were handed. The exact-match-first order is unchanged, so a backend genuinely
registered under a variant-looking name still keys on its own name.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5 [Claude Code]
This commit is contained in:
Ettore Di Giacinto
2026-07-27 08:30:32 +00:00
committed by localai-org-maint-bot
parent ce63c81e9c
commit 39eb743197
2 changed files with 40 additions and 5 deletions

View File

@@ -843,16 +843,29 @@ func UsesLlamaCppServingOptions(backend string) bool {
// Exact match FIRST, so a backend genuinely registered under a variant-looking
// name keeps its own entry and stripping can never shadow it.
func GetBackendCapability(backend string) *BackendCapability {
capability, _ := resolveBackendCapability(backend)
return capability
}
// resolveBackendCapability is GetBackendCapability plus the key the entry was
// found under. Callers that then branch on backend identity MUST use that key,
// not the name they passed in. VoiceCloningForModel is the reason this exists:
// its per-backend switch encodes which model variants of a backend can clone,
// and keying it on the caller's spelling meant "cuda12-vibevoice-cpp" resolved
// the capability by stripping but missed the "vibevoice-cpp" case, falling
// through to the permissive default and advertising cloning for the 0.5B model
// that cannot do it.
func resolveBackendCapability(backend string) (*BackendCapability, string) {
name := NormalizeBackendName(backend)
if cap, ok := BackendCapabilities[name]; ok {
return &cap
return &cap, name
}
if base := stripBackendVariant(name); base != name {
if cap, ok := BackendCapabilities[base]; ok {
return &cap
return &cap, base
}
}
return nil
return nil, name
}
// AudioTransformRequiresMono16kInput reports whether /audio/transform must fold
@@ -883,8 +896,7 @@ func VoiceCloningForModel(cfg *ModelConfig) *VoiceCloningCapability {
if cfg == nil {
return nil
}
backend := NormalizeBackendName(cfg.Backend)
capability := GetBackendCapability(backend)
capability, backend := resolveBackendCapability(cfg.Backend)
if capability == nil || capability.VoiceCloning == nil {
return nil
}

View File

@@ -222,6 +222,29 @@ var _ = Describe("VoiceCloningForModel", func() {
Entry("legacy option custom opt-in", ModelConfig{Name: "private-build", Backend: "qwen3-tts-cpp", Options: []string{"voice_cloning:true"}}, true),
Entry("legacy option opt-out", ModelConfig{Name: "voxcpm-1.5", Backend: "voxcpm", Options: []string{"voice_cloning=false"}}, false),
)
// A pinned gallery variant must reach the SAME per-backend rule as the meta
// name, in both directions. Resolving the capability by stripping the prefix
// while still keying the model-variant switch on the pinned spelling made
// every variant fall through to the permissive default: cuda12-vibevoice-cpp
// advertised cloning for the realtime 0.5B model, which cannot do it, and
// /v1/audio/speech accepted a profile: voice it had to fail on in the backend
// instead of rejecting it with a 400.
DescribeTable("resolves the model-variant rule through pinned gallery variants",
func(cfg ModelConfig, expected bool) {
Expect(VoiceCloningForModel(&cfg) != nil).To(Equal(expected))
},
Entry("cuda12-vibevoice-cpp 0.5B stays unsupported", ModelConfig{Name: "vibevoice-cpp-0.5b", Backend: "cuda12-vibevoice-cpp"}, false),
Entry("cuda12-vibevoice-cpp 1.5B stays supported", ModelConfig{Name: "vibevoice-1.5b", Backend: "cuda12-vibevoice-cpp"}, true),
Entry("metal-coqui tacotron2 stays unsupported", ModelConfig{Name: "tacotron2-en", Backend: "metal-coqui"}, false),
Entry("metal-coqui xtts stays supported", ModelConfig{Name: "xtts-v2", Backend: "metal-coqui"}, true),
Entry("cuda12-crispasr ASR stays unsupported", ModelConfig{Name: "parakeet-asr", Backend: "cuda12-crispasr"}, false),
Entry("cuda12-crispasr F5 stays supported", ModelConfig{Name: "f5-tts-crispasr", Backend: "cuda12-crispasr"}, true),
Entry("cpu-qwen3-tts-cpp CustomVoice stays unsupported", ModelConfig{Name: "qwen3-tts-flash", Backend: "cpu-qwen3-tts-cpp"}, false),
Entry("cpu-qwen3-tts-cpp Base stays supported", ModelConfig{Name: "qwen3-tts-cpp-0.6b-base", Backend: "cpu-qwen3-tts-cpp"}, true),
Entry("release channel suffix too", ModelConfig{Name: "vibevoice-cpp-0.5b", Backend: "vibevoice-cpp-development"}, false),
Entry("pinned audio-cpp keeps its unconditional cloning", ModelConfig{Name: "audio-cpp-chatterbox", Backend: "cuda12-audio-cpp"}, true),
)
})
var _ = Describe("IsValidUsecaseForBackend", func() {