From 39eb743197d60e7a6d02c694a5cab5569aacc43c Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Mon, 27 Jul 2026 08:30:32 +0000 Subject: [PATCH] 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 Assisted-by: Claude:claude-opus-5 [Claude Code] --- core/config/backend_capabilities.go | 22 +++++++++++++++++----- core/config/backend_capabilities_test.go | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/core/config/backend_capabilities.go b/core/config/backend_capabilities.go index ae0e0d9f4..385338b48 100644 --- a/core/config/backend_capabilities.go +++ b/core/config/backend_capabilities.go @@ -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 } diff --git a/core/config/backend_capabilities_test.go b/core/config/backend_capabilities_test.go index 93729a2a6..5a45d23bb 100644 --- a/core/config/backend_capabilities_test.go +++ b/core/config/backend_capabilities_test.go @@ -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() {