mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-11 08:48:07 -04:00
The WhisperImporter's Import() switch ordered LooksLikeURL ahead of the HuggingFace branch, so any https://huggingface.co/<owner>/<repo> URI (e.g. LocalAI-io/whisper-large-v3-it-yodas-only-ggml) hijacked the URL path. FilenameFromUrl returned the repo slug, the gallery entry pointed at the HTML repo page, the SHA256 was empty, and the HF file listing was effectively dead code for HTTPS imports. The HF branch only fired for huggingface://owner/repo and hf://owner/repo references. Gate the URL case on a "ggml-*.bin" basename signal — mirroring how the llama-cpp importer gates on ".gguf" — so direct file URLs still take the URL path while HF repo URLs fall through to the HF branch. There the file listing is actually consulted: every ggml-*.bin entry is collected and one is picked by the new preferences.quantizations preference (default q5_0; comma-separated for fallback ordering). Pin the chosen file under whisper/models/<name>/<file> so a single repo can ship q4_0/q5_0/q8_0 side-by-side without colliding on disk, matching the llama-cpp/models/<name>/ layout. The fallback when no preference matches is the last available ggml file, mirroring llama-cpp's pickPreferredGroup behaviour. Tests: replace the previous probe spec with positive assertions against LocalAI-io/whisper-large-v3-it-yodas-only-ggml (default → ggml-model-q5_0.bin, quantizations=q4_0 → ggml-model-q4_0.bin) plus two offline specs that build a fake hfapi.ModelDetails to cover the fallback rule and non-ggml filtering without touching the network. Assisted-by: Claude:claude-opus-4-7 [Bash Read Edit WebFetch] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
179 lines
5.4 KiB
Go
179 lines
5.4 KiB
Go
package importers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/gallery"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
"github.com/mudler/LocalAI/pkg/downloader"
|
|
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
|
|
"go.yaml.in/yaml/v2"
|
|
)
|
|
|
|
var _ Importer = &WhisperImporter{}
|
|
|
|
// WhisperImporter recognises whisper.cpp GGML models. The signals are narrow
|
|
// on purpose — whisper.cpp names its weights "ggml-*.bin" (e.g.
|
|
// ggml-base.en.bin) — so we match either a direct URL to such a file or an
|
|
// HF repo that ships one. preferences.backend="whisper" forces the importer
|
|
// regardless of artefacts.
|
|
type WhisperImporter struct{}
|
|
|
|
func (i *WhisperImporter) Name() string { return "whisper" }
|
|
func (i *WhisperImporter) Modality() string { return "asr" }
|
|
func (i *WhisperImporter) AutoDetects() bool { return true }
|
|
|
|
func (i *WhisperImporter) Match(details Details) bool {
|
|
preferences, err := details.Preferences.MarshalJSON()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
preferencesMap := make(map[string]any)
|
|
if len(preferences) > 0 {
|
|
if err := json.Unmarshal(preferences, &preferencesMap); err != nil {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if b, ok := preferencesMap["backend"].(string); ok && b == "whisper" {
|
|
return true
|
|
}
|
|
|
|
// Direct URL or path ending in ggml-*.bin
|
|
if isGGMLFilename(filepath.Base(details.URI)) {
|
|
return true
|
|
}
|
|
|
|
if details.HuggingFace != nil && HasGGMLFile(details.HuggingFace.Files, "ggml-") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (i *WhisperImporter) Import(details Details) (gallery.ModelConfig, error) {
|
|
preferences, err := details.Preferences.MarshalJSON()
|
|
if err != nil {
|
|
return gallery.ModelConfig{}, err
|
|
}
|
|
preferencesMap := make(map[string]any)
|
|
if len(preferences) > 0 {
|
|
if err := json.Unmarshal(preferences, &preferencesMap); err != nil {
|
|
return gallery.ModelConfig{}, err
|
|
}
|
|
}
|
|
|
|
name, ok := preferencesMap["name"].(string)
|
|
if !ok {
|
|
name = filepath.Base(details.URI)
|
|
}
|
|
|
|
description, ok := preferencesMap["description"].(string)
|
|
if !ok {
|
|
description = "Imported from " + details.URI
|
|
}
|
|
|
|
preferredQuants, _ := preferencesMap["quantizations"].(string)
|
|
quants := []string{"q5_0"}
|
|
if preferredQuants != "" {
|
|
quants = strings.Split(preferredQuants, ",")
|
|
}
|
|
|
|
cfg := gallery.ModelConfig{
|
|
Name: name,
|
|
Description: description,
|
|
}
|
|
|
|
modelConfig := config.ModelConfig{
|
|
Name: name,
|
|
Description: description,
|
|
Backend: "whisper",
|
|
KnownUsecaseStrings: []string{"transcript"},
|
|
}
|
|
|
|
uri := downloader.URI(details.URI)
|
|
directGGML := isGGMLFilename(filepath.Base(details.URI))
|
|
switch {
|
|
case uri.LooksLikeURL() && directGGML:
|
|
// Direct file URL (e.g. .../resolve/main/ggml-base.en.bin). We
|
|
// already know the exact file the user wants — no quant pick.
|
|
fileName, err := uri.FilenameFromUrl()
|
|
if err != nil {
|
|
return gallery.ModelConfig{}, err
|
|
}
|
|
target := filepath.Join("whisper", "models", name, fileName)
|
|
cfg.Files = append(cfg.Files, gallery.File{
|
|
URI: details.URI,
|
|
Filename: target,
|
|
})
|
|
modelConfig.PredictionOptions = schema.PredictionOptions{
|
|
BasicModelRequest: schema.BasicModelRequest{Model: target},
|
|
}
|
|
case details.HuggingFace != nil:
|
|
// HF repo: collect every ggml-*.bin, pick the preferred quant
|
|
// (default q5_0), nest under whisper/models/<name>/ so the same
|
|
// repo can ship multiple quants without colliding on disk.
|
|
var ggmlFiles []hfapi.ModelFile
|
|
for _, f := range details.HuggingFace.Files {
|
|
if isGGMLFilename(filepath.Base(f.Path)) {
|
|
ggmlFiles = append(ggmlFiles, f)
|
|
}
|
|
}
|
|
if chosen, ok := pickPreferredGGMLFile(ggmlFiles, quants); ok {
|
|
target := filepath.Join("whisper", "models", name, filepath.Base(chosen.Path))
|
|
cfg.Files = append(cfg.Files, gallery.File{
|
|
URI: chosen.URL,
|
|
Filename: target,
|
|
SHA256: chosen.SHA256,
|
|
})
|
|
modelConfig.PredictionOptions = schema.PredictionOptions{
|
|
BasicModelRequest: schema.BasicModelRequest{Model: target},
|
|
}
|
|
}
|
|
default:
|
|
// Bare URI with no HF metadata (pref-only path). Point the config at
|
|
// the URI basename so users can tweak the YAML after import.
|
|
modelConfig.PredictionOptions = schema.PredictionOptions{
|
|
BasicModelRequest: schema.BasicModelRequest{Model: filepath.Base(details.URI)},
|
|
}
|
|
}
|
|
|
|
data, err := yaml.Marshal(modelConfig)
|
|
if err != nil {
|
|
return gallery.ModelConfig{}, err
|
|
}
|
|
cfg.ConfigFile = string(data)
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// isGGMLFilename returns true when name follows whisper.cpp's "ggml-*.bin"
|
|
// packaging convention. The .bin check is case-insensitive; the ggml- prefix
|
|
// is exact.
|
|
func isGGMLFilename(name string) bool {
|
|
return strings.HasPrefix(name, "ggml-") && strings.HasSuffix(strings.ToLower(name), ".bin")
|
|
}
|
|
|
|
// pickPreferredGGMLFile walks prefs in order and returns the first ggml file
|
|
// whose basename contains any preference token (case-insensitive match on the
|
|
// quant suffix, e.g. "q5_0"). When no preference matches, falls back to the
|
|
// last file — mirroring llama-cpp's pickPreferredGroup behaviour so a missing
|
|
// quant still yields *something* the user can run.
|
|
func pickPreferredGGMLFile(files []hfapi.ModelFile, prefs []string) (hfapi.ModelFile, bool) {
|
|
if len(files) == 0 {
|
|
return hfapi.ModelFile{}, false
|
|
}
|
|
for _, pref := range prefs {
|
|
lower := strings.ToLower(pref)
|
|
for _, f := range files {
|
|
if strings.Contains(strings.ToLower(filepath.Base(f.Path)), lower) {
|
|
return f, true
|
|
}
|
|
}
|
|
}
|
|
return files[len(files)-1], true
|
|
}
|