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() {