LocalAI/pkg/huggingface-api/client_test.go

958 lines
29 KiB
Go
Raw Normal View History

package hfapi_test
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
)
var _ = Describe("HuggingFace API Client", func() {
var (
client *hfapi.Client
server *httptest.Server
)
BeforeEach(func() {
client = hfapi.NewClient()
})
AfterEach(func() {
if server != nil {
server.Close()
}
})
Context("when creating a new client", func() {
It("should initialize with correct base URL", func() {
Expect(client).ToNot(BeNil())
Expect(client.BaseURL()).To(Equal("https://huggingface.co/api/models"))
})
})
Context("when searching for models", func() {
BeforeEach(func() {
// Mock response data
mockResponse := `[
{
"modelId": "test-model-1",
"author": "test-author",
"downloads": 1000,
"lastModified": "2024-01-01T00:00:00.000Z",
"pipelineTag": "text-generation",
"private": false,
"tags": ["gguf", "llama"],
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z",
"sha": "abc123",
"config": {},
"model_index": "test-index",
"library_name": "transformers",
"mask_token": null,
"tokenizer_class": "LlamaTokenizer"
},
{
"modelId": "test-model-2",
"author": "test-author-2",
"downloads": 2000,
"lastModified": "2024-01-02T00:00:00.000Z",
"pipelineTag": "text-generation",
"private": false,
"tags": ["gguf", "mistral"],
"createdAt": "2024-01-02T00:00:00.000Z",
"updatedAt": "2024-01-02T00:00:00.000Z",
"sha": "def456",
"config": {},
"model_index": "test-index-2",
"library_name": "transformers",
"mask_token": null,
"tokenizer_class": "MistralTokenizer"
}
]`
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request parameters
Expect(r.URL.Query().Get("sort")).To(Equal("lastModified"))
Expect(r.URL.Query().Get("direction")).To(Equal("-1"))
Expect(r.URL.Query().Get("limit")).To(Equal("30"))
Expect(r.URL.Query().Get("search")).To(Equal("GGUF"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockResponse))
}))
// Override the client's base URL to use our mock server
client.SetBaseURL(server.URL)
})
It("should successfully search for models", func() {
params := hfapi.SearchParams{
Sort: "lastModified",
Direction: -1,
Limit: 30,
Search: "GGUF",
}
models, err := client.SearchModels(params)
Expect(err).ToNot(HaveOccurred())
Expect(models).To(HaveLen(2))
// Verify first model
Expect(models[0].ModelID).To(Equal("test-model-1"))
Expect(models[0].Author).To(Equal("test-author"))
Expect(models[0].Downloads).To(Equal(1000))
Expect(models[0].PipelineTag).To(Equal("text-generation"))
Expect(models[0].Private).To(BeFalse())
Expect(models[0].Tags).To(ContainElements("gguf", "llama"))
// Verify second model
Expect(models[1].ModelID).To(Equal("test-model-2"))
Expect(models[1].Author).To(Equal("test-author-2"))
Expect(models[1].Downloads).To(Equal(2000))
Expect(models[1].Tags).To(ContainElements("gguf", "mistral"))
})
It("should handle empty search results", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte("[]"))
}))
client.SetBaseURL(server.URL)
params := hfapi.SearchParams{
Sort: "lastModified",
Direction: -1,
Limit: 30,
Search: "nonexistent",
}
models, err := client.SearchModels(params)
Expect(err).ToNot(HaveOccurred())
Expect(models).To(HaveLen(0))
})
It("should handle HTTP errors", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error"))
}))
client.SetBaseURL(server.URL)
params := hfapi.SearchParams{
Sort: "lastModified",
Direction: -1,
Limit: 30,
Search: "GGUF",
}
models, err := client.SearchModels(params)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Status code: 500"))
Expect(models).To(BeNil())
})
It("should handle malformed JSON response", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte("invalid json"))
}))
client.SetBaseURL(server.URL)
params := hfapi.SearchParams{
Sort: "lastModified",
Direction: -1,
Limit: 30,
Search: "GGUF",
}
models, err := client.SearchModels(params)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse JSON response"))
Expect(models).To(BeNil())
})
})
Context("when getting latest GGUF models", func() {
BeforeEach(func() {
mockResponse := `[
{
"modelId": "latest-gguf-model",
"author": "gguf-author",
"downloads": 5000,
"lastModified": "2024-01-03T00:00:00.000Z",
"pipelineTag": "text-generation",
"private": false,
"tags": ["gguf", "latest"],
"createdAt": "2024-01-03T00:00:00.000Z",
"updatedAt": "2024-01-03T00:00:00.000Z",
"sha": "latest123",
"config": {},
"model_index": "latest-index",
"library_name": "transformers",
"mask_token": null,
"tokenizer_class": "LlamaTokenizer"
}
]`
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the search parameters are correct for GGUF search
Expect(r.URL.Query().Get("search")).To(Equal("GGUF"))
Expect(r.URL.Query().Get("sort")).To(Equal("lastModified"))
Expect(r.URL.Query().Get("direction")).To(Equal("-1"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockResponse))
}))
client.SetBaseURL(server.URL)
})
It("should fetch latest GGUF models with correct parameters", func() {
models, err := client.GetLatest("GGUF", 10)
Expect(err).ToNot(HaveOccurred())
Expect(models).To(HaveLen(1))
Expect(models[0].ModelID).To(Equal("latest-gguf-model"))
Expect(models[0].Author).To(Equal("gguf-author"))
Expect(models[0].Downloads).To(Equal(5000))
Expect(models[0].Tags).To(ContainElements("gguf", "latest"))
})
It("should use custom search term", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expect(r.URL.Query().Get("search")).To(Equal("custom-search"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte("[]"))
}))
client.SetBaseURL(server.URL)
models, err := client.GetLatest("custom-search", 5)
Expect(err).ToNot(HaveOccurred())
Expect(models).To(HaveLen(0))
})
})
Context("when handling network errors", func() {
It("should handle connection failures gracefully", func() {
// Use an invalid URL to simulate connection failure
client.SetBaseURL("http://invalid-url-that-does-not-exist")
params := hfapi.SearchParams{
Sort: "lastModified",
Direction: -1,
Limit: 30,
Search: "GGUF",
}
models, err := client.SearchModels(params)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to make request"))
Expect(models).To(BeNil())
})
})
Context("when getting file SHA on remote model", func() {
It("should get file SHA successfully", func() {
sha, err := client.GetFileSHA(
"mudler/LocalAI-functioncall-qwen2.5-7b-v0.5-Q4_K_M-GGUF", "localai-functioncall-qwen2.5-7b-v0.5-q4_k_m.gguf")
Expect(err).ToNot(HaveOccurred())
Expect(sha).To(Equal("4e7b7fe1d54b881f1ef90799219dc6cc285d29db24f559c8998d1addb35713d4"))
})
})
Context("when listing files", func() {
BeforeEach(func() {
mockFilesResponse := `[
{
"type": "file",
"path": "model-Q4_K_M.gguf",
"size": 1000000,
"oid": "abc123",
"lfs": {
"oid": "def456789",
"size": 1000000,
"pointerSize": 135
}
},
{
"type": "file",
"path": "README.md",
"size": 5000,
"oid": "readme123"
},
{
"type": "file",
"path": "config.json",
"size": 1000,
"oid": "config123"
}
]`
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/tree/main") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockFilesResponse))
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
client.SetBaseURL(server.URL)
})
It("should list files successfully", func() {
files, err := client.ListFiles("test/model")
Expect(err).ToNot(HaveOccurred())
Expect(files).To(HaveLen(3))
Expect(files[0].Path).To(Equal("model-Q4_K_M.gguf"))
Expect(files[0].Size).To(Equal(int64(1000000)))
Expect(files[0].LFS).ToNot(BeNil())
Expect(files[0].LFS.Oid).To(Equal("def456789"))
Expect(files[1].Path).To(Equal("README.md"))
Expect(files[1].Size).To(Equal(int64(5000)))
})
})
Context("when listing files with subfolders", func() {
BeforeEach(func() {
// Mock response for root directory with files and a subfolder
mockRootResponse := `[
{
"type": "file",
"path": "README.md",
"size": 5000,
"oid": "readme123"
},
{
"type": "directory",
"path": "subfolder",
"size": 0,
"oid": "dir123"
},
{
"type": "file",
"path": "config.json",
"size": 1000,
"oid": "config123"
}
]`
// Mock response for subfolder directory
mockSubfolderResponse := `[
{
"type": "file",
"path": "subfolder/file.bin",
"size": 2000000,
"oid": "filebin123",
"lfs": {
"oid": "filebin456",
"size": 2000000,
"pointerSize": 135
}
},
{
"type": "directory",
"path": "nested",
"size": 0,
"oid": "nesteddir123"
}
]`
// Mock response for nested subfolder
mockNestedResponse := `[
{
"type": "file",
"path": "subfolder/nested/nested_file.gguf",
"size": 5000000,
"oid": "nested123",
"lfs": {
"oid": "nested456",
"size": 5000000,
"pointerSize": 135
}
}
]`
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if strings.Contains(urlPath, "/tree/main/subfolder/nested") {
w.Write([]byte(mockNestedResponse))
} else if strings.Contains(urlPath, "/tree/main/subfolder") {
w.Write([]byte(mockSubfolderResponse))
} else if strings.Contains(urlPath, "/tree/main") {
w.Write([]byte(mockRootResponse))
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
client.SetBaseURL(server.URL)
})
It("should recursively list all files including those in subfolders", func() {
files, err := client.ListFiles("test/model")
Expect(err).ToNot(HaveOccurred())
Expect(files).To(HaveLen(4))
// Verify root level files
readmeFile := findFileByPath(files, "README.md")
Expect(readmeFile).ToNot(BeNil())
Expect(readmeFile.Size).To(Equal(int64(5000)))
Expect(readmeFile.Oid).To(Equal("readme123"))
configFile := findFileByPath(files, "config.json")
Expect(configFile).ToNot(BeNil())
Expect(configFile.Size).To(Equal(int64(1000)))
Expect(configFile.Oid).To(Equal("config123"))
// Verify subfolder file with relative path
subfolderFile := findFileByPath(files, "subfolder/file.bin")
Expect(subfolderFile).ToNot(BeNil())
Expect(subfolderFile.Size).To(Equal(int64(2000000)))
Expect(subfolderFile.LFS).ToNot(BeNil())
Expect(subfolderFile.LFS.Oid).To(Equal("filebin456"))
// Verify nested subfolder file
nestedFile := findFileByPath(files, "subfolder/nested/nested_file.gguf")
Expect(nestedFile).ToNot(BeNil())
Expect(nestedFile.Size).To(Equal(int64(5000000)))
Expect(nestedFile.LFS).ToNot(BeNil())
Expect(nestedFile.LFS.Oid).To(Equal("nested456"))
})
It("should handle files with correct relative paths", func() {
files, err := client.ListFiles("test/model")
Expect(err).ToNot(HaveOccurred())
// Check that all paths are relative and correct
paths := make([]string, len(files))
for i, file := range files {
paths[i] = file.Path
}
Expect(paths).To(ContainElements(
"README.md",
"config.json",
"subfolder/file.bin",
"subfolder/nested/nested_file.gguf",
))
})
})
Context("when getting file SHA", func() {
BeforeEach(func() {
mockFilesResponse := `[
{
"type": "file",
"path": "model-Q4_K_M.gguf",
"size": 1000000,
"oid": "abc123",
"lfs": {
"oid": "def456789",
"size": 1000000,
"pointerSize": 135
}
}
]`
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/tree/main") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockFilesResponse))
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
client.SetBaseURL(server.URL)
})
It("should get file SHA successfully", func() {
sha, err := client.GetFileSHA("test/model", "model-Q4_K_M.gguf")
Expect(err).ToNot(HaveOccurred())
Expect(sha).To(Equal("def456789"))
})
It("should handle missing SHA gracefully", func() {
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/tree/main") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[
{
"type": "file",
"path": "file.txt",
"size": 100,
"oid": "file123"
}
]`))
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
client.SetBaseURL(server.URL)
sha, err := client.GetFileSHA("test/model", "file.txt")
Expect(err).ToNot(HaveOccurred())
// When there's no LFS, it should return the OID
Expect(sha).To(Equal("file123"))
})
})
Context("when getting model details", func() {
BeforeEach(func() {
mockFilesResponse := `[
{
"type": "file",
"path": "model-Q4_K_M.gguf",
"size": 1000000,
"oid": "abc123",
"lfs": {
"oid": "sha256:def456",
"size": 1000000,
"pointer": "version https://git-lfs.github.com/spec/v1",
"sha256": "def456789"
}
},
{
"type": "file",
"path": "README.md",
"size": 5000,
"oid": "readme123"
}
]`
mockFileInfoResponse := `{
"path": "model-Q4_K_M.gguf",
"size": 1000000,
"oid": "abc123",
"lfs": {
"oid": "sha256:def456",
"size": 1000000,
"pointer": "version https://git-lfs.github.com/spec/v1",
"sha256": "def456789"
}
}`
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/tree/main") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockFilesResponse))
} else if strings.Contains(r.URL.Path, "/paths-info") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockFileInfoResponse))
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
client.SetBaseURL(server.URL)
})
It("should get model details successfully", func() {
details, err := client.GetModelDetails("test/model")
Expect(err).ToNot(HaveOccurred())
Expect(details.ModelID).To(Equal("test/model"))
Expect(details.Author).To(Equal("test"))
Expect(details.Files).To(HaveLen(2))
Expect(details.ReadmeFile).ToNot(BeNil())
Expect(details.ReadmeFile.Path).To(Equal("README.md"))
Expect(details.ReadmeFile.IsReadme).To(BeTrue())
// Verify URLs are set for all files
baseURL := strings.TrimSuffix(server.URL, "/api/models")
for _, file := range details.Files {
expectedURL := fmt.Sprintf("%s/test/model/resolve/main/%s", baseURL, file.Path)
Expect(file.URL).To(Equal(expectedURL))
}
})
})
Context("when getting README content", func() {
BeforeEach(func() {
mockReadmeContent := "# Test Model\n\nThis is a test model for demonstration purposes."
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/raw/main/") {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockReadmeContent))
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
client.SetBaseURL(server.URL)
})
It("should get README content successfully", func() {
content, err := client.GetReadmeContent("test/model", "README.md")
Expect(err).ToNot(HaveOccurred())
Expect(content).To(Equal("# Test Model\n\nThis is a test model for demonstration purposes."))
})
})
Context("when filtering files", func() {
It("should filter files by quantization", func() {
files := []hfapi.ModelFile{
{Path: "model-Q4_K_M.gguf"},
{Path: "model-Q3_K_M.gguf"},
{Path: "README.md", IsReadme: true},
}
filtered := hfapi.FilterFilesByQuantization(files, "Q4_K_M")
Expect(filtered).To(HaveLen(1))
Expect(filtered[0].Path).To(Equal("model-Q4_K_M.gguf"))
})
It("should find preferred model file", func() {
files := []hfapi.ModelFile{
{Path: "model-Q3_K_M.gguf"},
{Path: "model-Q4_K_M.gguf"},
{Path: "README.md", IsReadme: true},
}
preferences := []string{"Q4_K_M", "Q3_K_M"}
preferred := hfapi.FindPreferredModelFile(files, preferences)
Expect(preferred).ToNot(BeNil())
Expect(preferred.Path).To(Equal("model-Q4_K_M.gguf"))
Expect(preferred.IsReadme).To(BeFalse())
})
It("should return nil if no preferred file found", func() {
files := []hfapi.ModelFile{
{Path: "model-Q2_K.gguf"},
{Path: "README.md", IsReadme: true},
}
preferences := []string{"Q4_K_M", "Q3_K_M"}
preferred := hfapi.FindPreferredModelFile(files, preferences)
Expect(preferred).To(BeNil())
})
It("should return shard 1 when the preferred quant is multi-part", func() {
// Regression coverage for PR #9510: an unsloth-style sharded
// GGUF repo listed shards in mixed order — the old implementation
// returned whichever shard happened to come first in the slice.
// We now always return shard 1 so llama.cpp can discover the set.
files := []hfapi.ModelFile{
{Path: "Kimi-K2.6-UD-Q8_K_XL-00003-of-00014.gguf"},
{Path: "Kimi-K2.6-UD-Q8_K_XL-00001-of-00014.gguf"},
{Path: "Kimi-K2.6-UD-Q8_K_XL-00002-of-00014.gguf"},
{Path: "README.md", IsReadme: true},
}
preferred := hfapi.FindPreferredModelFile(files, []string{"Q8_K_XL"})
Expect(preferred).ToNot(BeNil())
Expect(preferred.Path).To(Equal("Kimi-K2.6-UD-Q8_K_XL-00001-of-00014.gguf"))
})
It("should honour priority order for sharded repos", func() {
// Two shard sets live in the repo (Q4_K_M and Q8_0). Callers
// pass a priority list; the first preference that has a matching
// group wins, and its shard 1 is returned.
files := []hfapi.ModelFile{
{Path: "model-Q8_0-00001-of-00002.gguf"},
{Path: "model-Q8_0-00002-of-00002.gguf"},
{Path: "model-Q4_K_M-00001-of-00002.gguf"},
{Path: "model-Q4_K_M-00002-of-00002.gguf"},
}
preferred := hfapi.FindPreferredModelFile(files, []string{"Q4_K_M", "Q8_0"})
Expect(preferred).ToNot(BeNil())
Expect(preferred.Path).To(Equal("model-Q4_K_M-00001-of-00002.gguf"))
})
})
Context("shard grouping", func() {
DescribeTable("SplitShardSuffix",
func(name, expectedBase string, expectedIdx, expectedTotal int, expectedOK bool) {
base, idx, total, ok := hfapi.SplitShardSuffix(name)
Expect(ok).To(Equal(expectedOK))
Expect(base).To(Equal(expectedBase))
Expect(idx).To(Equal(expectedIdx))
Expect(total).To(Equal(expectedTotal))
},
Entry("5-digit zero-padded (canonical)",
"Kimi-K2.6-UD-Q8_K_XL-00001-of-00014.gguf",
"Kimi-K2.6-UD-Q8_K_XL.gguf", 1, 14, true),
Entry("5-digit, last shard",
"Kimi-K2.6-UD-Q8_K_XL-00014-of-00014.gguf",
"Kimi-K2.6-UD-Q8_K_XL.gguf", 14, 14, true),
Entry("2-digit width",
"model-01-of-03.gguf",
"model.gguf", 1, 3, true),
Entry("mixed-case extension",
"model-00001-of-00002.GGUF",
"model.gguf", 1, 2, true),
Entry("single-file model returns ok=false",
"model-Q4_K_M.gguf", "", 0, 0, false),
Entry("mmproj is not a shard",
"mmproj-F16.gguf", "", 0, 0, false),
Entry("non-gguf extension is not a shard",
"model-00001-of-00002.bin", "", 0, 0, false),
Entry("naked suffix without leading dash is not matched",
"00001-of-00002.gguf", "", 0, 0, false),
// Guard against over-greedy matching: the pattern looks only at
// the suffix, so numbers earlier in the name must be ignored.
Entry("digits earlier in the name don't confuse the match",
"model-v2-Q4_K_M-00003-of-00005.gguf",
"model-v2-Q4_K_M.gguf", 3, 5, true),
)
It("groups sharded GGUF files by base and sorts by shard index", func() {
// Feed shards in unsorted order and interleaved with other
// files to exercise both the grouping and the sort.
files := []hfapi.ModelFile{
{Path: "Kimi-K2.6-UD-Q8_K_XL-00014-of-00014.gguf"},
{Path: "mmproj-F32.gguf"},
{Path: "Kimi-K2.6-UD-Q8_K_XL-00001-of-00014.gguf"},
{Path: "Kimi-K2.6-UD-Q8_K_XL-00002-of-00014.gguf"},
}
groups := hfapi.GroupShards(files)
Expect(groups).To(HaveLen(2))
// First group: the sharded Kimi set — first appearance was the
// 14-of-14 shard, so this group leads.
Expect(groups[0].Sharded).To(BeTrue())
Expect(groups[0].Base).To(Equal("Kimi-K2.6-UD-Q8_K_XL.gguf"))
Expect(groups[0].Total).To(Equal(14))
Expect(groups[0].Files).To(HaveLen(3))
Expect(groups[0].Files[0].Path).To(Equal("Kimi-K2.6-UD-Q8_K_XL-00001-of-00014.gguf"))
Expect(groups[0].Files[1].Path).To(Equal("Kimi-K2.6-UD-Q8_K_XL-00002-of-00014.gguf"))
Expect(groups[0].Files[2].Path).To(Equal("Kimi-K2.6-UD-Q8_K_XL-00014-of-00014.gguf"))
// Second group: the mmproj singleton.
Expect(groups[1].Sharded).To(BeFalse())
Expect(groups[1].Base).To(Equal("mmproj-F32.gguf"))
Expect(groups[1].Files).To(HaveLen(1))
})
It("preserves non-shard files as one-entry groups", func() {
files := []hfapi.ModelFile{
{Path: "model-Q4_K_M.gguf"},
{Path: "model-Q3_K_M.gguf"},
}
groups := hfapi.GroupShards(files)
Expect(groups).To(HaveLen(2))
Expect(groups[0].Sharded).To(BeFalse())
Expect(groups[0].Files[0].Path).To(Equal("model-Q4_K_M.gguf"))
Expect(groups[1].Sharded).To(BeFalse())
Expect(groups[1].Files[0].Path).To(Equal("model-Q3_K_M.gguf"))
})
It("groups files that live in subfolders by their basename", func() {
// HuggingFace repos often place shards in a per-quant subfolder
// (bartowski/* is a common example). The Path includes the
// folder but we must still group by basename.
files := []hfapi.ModelFile{
{Path: "Q4_K_M/model-Q4_K_M-00001-of-00002.gguf"},
{Path: "Q4_K_M/model-Q4_K_M-00002-of-00002.gguf"},
}
groups := hfapi.GroupShards(files)
Expect(groups).To(HaveLen(1))
Expect(groups[0].Sharded).To(BeTrue())
Expect(groups[0].Total).To(Equal(2))
Expect(groups[0].Files).To(HaveLen(2))
})
})
Context("integration test with real HuggingFace API", func() {
It("should recursively list all files including subfolders from real repository", func() {
// This test makes actual API calls to HuggingFace
// Skip if running in CI or if network is not available
realClient := hfapi.NewClient()
repoID := "bartowski/Qwen_Qwen3-Next-80B-A3B-Instruct-GGUF"
files, err := realClient.ListFiles(repoID)
Expect(err).ToNot(HaveOccurred())
Expect(files).ToNot(BeEmpty(), "should return at least some files")
// Verify that we get files from subfolders
// Based on the repository structure, there should be files in subfolders like:
// - Qwen_Qwen3-Next-80B-A3B-Instruct-Q4_1/...
// - Qwen_Qwen3-Next-80B-A3B-Instruct-Q5_K_L/...
// etc.
hasSubfolderFiles := false
rootLevelFiles := 0
subfolderFiles := 0
for _, file := range files {
if strings.Contains(file.Path, "/") {
hasSubfolderFiles = true
subfolderFiles++
// Verify the path format is correct (subfolder/file.gguf)
Expect(file.Path).ToNot(HavePrefix("/"), "paths should be relative, not absolute")
Expect(file.Path).ToNot(HaveSuffix("/"), "file paths should not end with /")
} else {
rootLevelFiles++
}
}
Expect(hasSubfolderFiles).To(BeTrue(), "should find files in subfolders")
Expect(rootLevelFiles).To(BeNumerically(">", 0), "should find files at root level")
Expect(subfolderFiles).To(BeNumerically(">", 0), "should find files in subfolders")
// Verify specific expected files exist
// Root level files
readmeFile := findFileByPath(files, "README.md")
Expect(readmeFile).ToNot(BeNil(), "README.md should exist at root level")
// Verify we can find files in subfolders
// Look for any file in a subfolder (the exact structure may vary, can be nested)
foundSubfolderFile := false
for _, file := range files {
if strings.Contains(file.Path, "/") && strings.HasSuffix(file.Path, ".gguf") {
foundSubfolderFile = true
// Verify the path structure: can be nested like subfolder/subfolder/file.gguf
parts := strings.Split(file.Path, "/")
Expect(len(parts)).To(BeNumerically(">=", 2), "subfolder files should have at least subfolder/file.gguf format")
// The last part should be the filename
Expect(parts[len(parts)-1]).To(HaveSuffix(".gguf"), "file in subfolder should be a .gguf file")
Expect(parts[len(parts)-1]).ToNot(BeEmpty(), "filename should not be empty")
break
}
}
Expect(foundSubfolderFile).To(BeTrue(), "should find at least one .gguf file in a subfolder")
// Verify file properties are populated
for _, file := range files {
Expect(file.Path).ToNot(BeEmpty(), "file path should not be empty")
Expect(file.Type).To(Equal("file"), "all returned items should be files, not directories")
// Size might be 0 for some files, but OID should be present
if file.LFS == nil {
Expect(file.Oid).ToNot(BeEmpty(), "file should have an OID if no LFS")
}
}
})
feat(importer): expand importer flow to almost all backends (#9466) * docs(agents): require importer integration when adding backends Document the importer registry workflow so contributors know that adding a new backend also requires updating the /import-model dropdown source: either a new importer in core/gallery/importers/, extending an existing one for drop-in replacements, or the pref-only slice for backends with no reliable auto-detect signal. Always covered by a table-driven test. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for Batch 0 primitives Introduce failing tests that drive Batch 0 of the importer expansion: - pkg/huggingface-api: assert GetModelDetails populates PipelineTag and LibraryName from /api/models/{repo}, and that a failing metadata endpoint still returns file details (best-effort fetch). - core/gallery/importers/helpers_test.go: new table-driven coverage for HasFile, HasExtension, HasONNX, HasONNXConfigPair, HasGGMLFile. - core/gallery/importers/importers_test.go: assert ErrAmbiguousImport sentinel exists and round-trips through errors.Is. - core/gallery/importers/local_test.go: extend with detection cases for ggml-*.bin (whisper), silero_vad.onnx (silero-vad), and the piper .onnx + .onnx.json pair. - core/http/endpoints/localai/import_model_test.go: assert ImportModelURIEndpoint returns HTTP 400 with a structured {error, detail, hint} body when ErrAmbiguousImport surfaces. All tests fail in the expected places (missing fields, missing helpers, missing sentinel, endpoint still wraps as 500). Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): Batch 0 foundation — helpers, sentinel, local detection Implements the Batch 0 primitives that subsequent importer batches build on: - pkg/huggingface-api: ModelDetails gains PipelineTag and LibraryName. GetModelDetails now layers a best-effort GET /api/models/{repo} fetch on top of ListFiles — a metadata outage leaves the fields empty but still returns full file details. Uses a dedicated response struct because the single-model endpoint uses snake_case keys while the list endpoint historically returned camelCase. - core/gallery/importers/helpers.go: generic HasFile, HasExtension, HasONNX, HasONNXConfigPair, HasGGMLFile helpers working on []hfapi.ModelFile so per-backend importers can detect artefact patterns without duplicating string wrangling. - core/gallery/importers/importers.go: adds the ErrAmbiguousImport sentinel. DiscoverModelConfig now returns it (wrapped with fmt.Errorf("%w: ...")) when no importer matched AND the HF pipeline_tag falls in a whitelist of narrow modalities (ASR, TTS, sentence-similarity, text-classification, object-detection). The whitelist is intentionally narrow — unknown tags keep the previous "no importer matched" behaviour to avoid blocking rare repos. - core/gallery/importers/local.go: three new local-path detections, inserted before the existing merged-transformers branch: * ggml-*.bin → whisper * silero*.onnx → silero-vad * *.onnx + *.onnx.json pair → piper - core/http/endpoints/localai/import_model.go: ImportModelURIEndpoint surfaces ErrAmbiguousImport as HTTP 400 with {error, detail, hint} JSON, preserving existing behaviour for unrelated errors. Green tests: go test ./core/gallery/importers/... ./pkg/huggingface-api/... \ ./core/http/endpoints/localai/... Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(importers): red tests for KnownBackend endpoint and importer metadata Add failing tests that drive Batch UI-Dropdown: - importers_test.go: assert importers expose Name/Modality/AutoDetects and that LlamaCPPImporter advertises drop-in replacements via a new AdditionalBackendsProvider interface. A Registry() accessor is also expected. - backend_test.go (new): assert GET /backends/known returns []schema.KnownBackend, covers every importer, exposes drop-in llama-cpp replacements, includes curated pref-only backends, has no duplicates, and is sorted by Modality+Name. These tests fail at compile time against master; they are intentionally red so the follow-up green commit is reviewable. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery): add /backends/known endpoint for importer-aware backend list Extend the Importer interface with Name/Modality/AutoDetects so the import system can self-describe its registry, and introduce the AdditionalBackendsProvider interface so importers can advertise drop-in replacements (llama-cpp advertises ik-llama-cpp and turboquant). Expose the new GET /backends/known endpoint that merges: - the importer registry (auto-detect supported), - drop-in replacements hosted by importers (preference-only), - a curated knownPrefOnlyBackends slice for backends with no dedicated importer (sglang, tinygrad, trl, mlx-vlm, whisperx, kokoros, Qwen TTS variants, sam3-cpp) — kept at the top of backend.go so contributors adding a new pref-only backend have one obvious place to edit, - backends installed on disk but unknown to the importer (marked AutoDetect=false, empty Modality). The endpoint deliberately does NOT filter by gallery membership or host capability (unlike /backends/available): LocalAI may auto-install a backend that is not yet present, so the import form dropdown must show everything the importer knows about. Response is deduplicated (importer wins over pref-only) and sorted by Modality+Name for deterministic output. Registered in core/http/routes/localai.go next to /backends/available under the same admin middleware. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(ui): source import form backend dropdown from /backends/known Replace the hard-coded BACKENDS constant in ImportModel.jsx with a live fetch of /backends/known on mount. Users now see every backend the importer layer knows about (including preference-only entries) grouped by modality, not a stale subset. Changes: - config.js: add backendsKnown endpoint constant next to backendsAvailable. - api.js: add backendsApi.listKnown() wrapper. - ImportModel.jsx: remove BACKENDS constant, fetch the list via useEffect, and derive grouped options via buildBackendOptions. Preference-only entries render with a " (preference-only)" suffix. Loading state disables the dropdown with a "Loading backends…" placeholder; on fetch failure the form falls back to auto-detect only and surfaces a non-blocking toast. - SearchableSelect.jsx: accept items flagged isHeader=true and render them as non-selectable section dividers. Keyboard navigation skips headers and search queries hide them so filtered output stays relevant. Vitest is not set up in this project (devDependencies ship Playwright only). Per the brief's guard-rail, no frontend test framework is introduced; coverage is provided by the Go handler tests that assert the /backends/known contract consumed by the React form. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for whisper importer Asserts detection on ggerganov/whisper.cpp (via ggml-*.bin filename), the preferences.backend=whisper override path for arbitrary URIs, and the Importer interface metadata (name/modality/autodetect). Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add whisper importer Recognises whisper.cpp GGML models by the "ggml-*.bin" filename convention (direct URL or HF repo member) and by the explicit preferences.backend="whisper" override. Emits backend: whisper with the transcript use-case. Registered before llama-cpp so the narrow filename signal wins before any generic GGUF match is attempted. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for moonshine importer Asserts detection on UsefulSensors/moonshine-tiny via owner + ONNX files, the preferences.backend=moonshine override for arbitrary URIs, and the Importer interface metadata (name/modality/autodetect). Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add moonshine importer Matches UsefulSensors-owned HF repos whose artefacts or metadata identify them as ASR: on-disk .onnx files (the canonical Moonshine packaging) OR pipeline_tag=automatic-speech-recognition (covers transformers/safetensors-only sibling repos). preferences.backend= moonshine overrides detection. Test uses the live moonshine-tiny repo because the canonical UsefulSensors/moonshine repo currently hits a recursive-subfolder bug in pkg/huggingface-api ListFiles. Registered after WhisperImporter but before LlamaCPPImporter and TransformersImporter so the narrower owner+ASR signal wins before the generic tokenizer.json check routes the repo to transformers. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for nemo importer Asserts detection on nvidia/parakeet-tdt-0.6b-v3 via owner + .nemo file, the preferences.backend=nemo override for arbitrary URIs, and the Importer interface metadata (name/modality/autodetect). Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add nemo importer Matches nvidia-owned HF repos that ship a .nemo checkpoint archive, the canonical NeMo ASR packaging. preferences.backend=nemo forces detection. Registered between moonshine and llama-cpp so the narrow owner + extension signal wins before any downstream generic matcher. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for faster-whisper importer Asserts detection on Systran/faster-whisper-large-v3 (owner + model.bin + config.json + ASR pipeline), the preferences.backend= faster-whisper override for arbitrary URIs, and the Importer interface metadata. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add faster-whisper importer Recognises CTranslate2-packaged whisper checkpoints distributed for the faster-whisper runtime: model.bin + config.json + ASR pipeline_tag, narrowed to Systran-owned repos or repo names containing "faster-whisper" to avoid falsely claiming vanilla OpenAI whisper HF repos. preferences.backend=faster-whisper overrides detection. Registered before llama-cpp and transformers so the narrow signal wins before tokenizer.json routes the repo to the generic transformers importer. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for qwen-asr importer Asserts detection on Qwen/Qwen3-ASR-1.7B via owner + ASR substring in the repo name, the preferences.backend=qwen-asr override for arbitrary URIs, and the Importer interface metadata. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add qwen-asr importer Matches Qwen-owned HF repos whose name contains "ASR" (case-insensitive), routing them to the qwen-asr backend rather than the generic transformers/vllm path. The substring check scans the repo portion only so the owner field cannot leak a false match. preferences.backend=qwen-asr forces detection. Registered before llama-cpp and transformers so the narrow owner+name signal wins. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): ASR ambiguity surfaces ErrAmbiguousImport Locks in the behaviour added in Batch 0: an HF repo whose pipeline_tag marks it as automatic-speech-recognition but whose artefacts match no ASR importer (and no generic importer) must fail with ErrAmbiguousImport so callers know to pass preferences.backend rather than silently guess. pyannote/voice-activity-detection is the fixture — its file list is only config.yaml + README, leaving every importer's artefact check negative. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for piper importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add piper importer Detects piper TTS voices by the canonical <voice>.onnx + <voice>.onnx.json pair packaging (via HasONNXConfigPair). Narrow enough to skip generic ONNX repos used by other backends (Moonshine ASR, sentence-transformers). Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for bark importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add bark importer Detects Suno's Bark TTS checkpoints by HF owner "suno" + repo name prefix "bark". Adds HFOwnerRepoFromURI() helper so importers can fall back to URI parsing when pkg/huggingface-api's recursive tree listing errors on repos with nested subdirectories (suno/bark ships a speaker_embeddings/v2 subtree that trips a pre-existing path-doubling bug in the listFilesInPath recursion). Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for fish-speech importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add fish-speech importer Detects Fish Audio TTS releases by HF owner "fishaudio" with a URI-based fallback for repos whose tree recursion trips the pre-existing hfapi path-doubling bug. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for outetts importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add outetts importer Detects OuteAI's OuteTTS releases by HF owner "OuteAI" or a case- insensitive "OuteTTS" substring in the repo name, with a URI-based fallback for recursion-bugged repos. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for voxcpm importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add voxcpm importer Detects OpenBMB's VoxCPM TTS family by repo-name substring (community mirrors re-host the weights under many owners — mlx-community, bluryar, callgg, etc). Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for kokoro importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add kokoro importer Detects hexgrad's Kokoro TTS by the "Kokoro" repo-name substring paired with a PyTorch .pth/.pt checkpoint — the pairing excludes ONNX-only mirrors (handled by the pref-only `kokoros` Rust runtime) and GGUF mirrors (handled by llama-cpp). Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for kitten-tts importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add kitten-tts importer Detects KittenML's kitten-tts releases by owner or "kitten-tts" repo-name substring, with URI-parsing fallback. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for neutts importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add neutts importer Detects Neuphonic's NeuTTS releases by owner "neuphonic" or "neutts" repo-name substring, with URI-parsing fallback. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for chatterbox importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add chatterbox importer Detects Resemble AI's Chatterbox TTS by owner "ResembleAI" or "chatterbox" repo-name substring, with URI-parsing fallback. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for vibevoice importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add vibevoice importer Detects Microsoft's VibeVoice TTS by "vibevoice" repo-name substring (case-insensitive) so community mirrors still route here. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for coqui importer Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add coqui importer Detects Coqui AI's TTS releases (XTTS-v2, YourTTS, …) by the authoritative `coqui` HF owner, with URI-parsing fallback. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): TTS ambiguity surfaces ErrAmbiguousImport Adds a Ginkgo spec that imports nari-labs/Dia-1.6B — a real HF repo carrying pipeline_tag="text-to-speech" whose artefacts (*.pth, one safetensors shard, preprocessor_config.json, config.json) match none of the Batch-2 TTS importers nor the generic text/image importers — and asserts DiscoverModelConfig wraps ErrAmbiguousImport via errors.Is. Also pivots the endpoint-level ambiguity fixture from hexgrad/Kokoro-82M to nari-labs/Dia-1.6B. Batch 2 added a dedicated kokoro importer that now claims the original fixture; Dia remains genuinely unclaimed and so exercises the same ambiguity code path at the HTTP layer. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for stablediffusion-ggml importer Covers HF repo detection (city96/FLUX.1-dev-gguf), raw .gguf URL matching on filename arch tokens, preference override, and Importer interface metadata. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add stablediffusion-ggml importer Detects GGUF-packed Stable Diffusion and FLUX checkpoints (leejet owner, city96 FLUX mirrors, second-state SD dumps, raw .gguf URLs with arch tokens) and routes them to the stablediffusion-ggml backend. Registered BEFORE LlamaCPPImporter so .gguf image checkpoints are not stolen by llama-cpp's generic .gguf match. Reuses HFOwnerRepoFromURI for the hfapi-recursion-bug fallback. preferences.backend overrides detection. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for ace-step importer Covers HF repo-name detection (ACE-Step/ACE-Step-v1-3.5B), preference override, and Importer interface metadata. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add ace-step importer Routes ACE-Step music generation checkpoints (ACE-Step/ACE-Step-v1-3.5B, ACE-Step/Ace-Step1.5, community mirrors) to the ace-step backend. Matching is case-insensitive on the "ace-step" repo-name substring and owner, with an HFOwnerRepoFromURI fallback for the hfapi recursion bug. KnownUsecaseStrings mirrors the gallery's ace-step-turbo entry (sound_generation, tts). preferences.backend overrides. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): surface ErrAmbiguousImport on text-to-image misses Adds text-to-image to ambiguousModalities whitelist and covers the h94/IP-Adapter-FaceID case — pipeline_tag=text-to-image but ships only .bin/.safetensors so diffusers, stablediffusion-ggml, llama-cpp, transformers, vllm, mlx, and ace-step all miss. DiscoverModelConfig now surfaces ErrAmbiguousImport for that shape instead of the opaque "no importer matched" error. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for vllm-omni importer Introduces the test surface for the forthcoming VLLMOmniImporter: detection via preferences.backend, Qwen owner + Omni repo token, URI-only fallback, negative cases (plain Qwen, random OmniX repo), and Import() emitting backend: vllm-omni with chat + multimodal usecases. Includes a registration-order assertion via DiscoverModelConfig to pin the requirement that vllm-omni wins over vllm for Qwen Omni repos (tokenizer files are usually present too). Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add vllm-omni importer Adds VLLMOmniImporter for Qwen Omni-style multimodal checkpoints (Qwen3-Omni, Qwen2.5-Omni, …). Detection is narrow: HF owner "Qwen" combined with "omni" in the repo name, or a repo name matching the -Omni-/Omni- naming pattern. preferences.backend="vllm-omni" always wins; HFOwnerRepoFromURI provides a URI-only fallback for the hfapi recursion-bug edge case. Emitted YAML sets backend: vllm-omni and known_usecases: [chat, multimodal], matching the gallery/index.yaml vllm-omni entries. The importer is registered ahead of VLLMImporter so Qwen Omni repos — which also carry tokenizer files — route to vllm-omni rather than the plain vllm backend. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for llama-cpp drop-in preferences Pins the expected drop-in replacement behaviour: preferences.backend of ik-llama-cpp or turboquant must swap the emitted YAML backend field while keeping the llama-cpp file layout identical. Also covers the unknown-backend case (must stay llama-cpp) and re-asserts AdditionalBackends() returns the two curated entries with non-empty descriptions. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): llama-cpp honours ik-llama-cpp and turboquant drop-in preferences preferences.backend set to ik-llama-cpp or turboquant now swaps the emitted YAML backend field while leaving the file layout, model path, mmproj handling and everything else in the llama-cpp Import pipeline untouched. Unknown values are ignored and fall back to backend: llama-cpp so arbitrary input can't leak into the config. Aligns the AdditionalBackends() descriptions with the user-facing naming conventions surfaced via /backends/known. No changes to the pref-only curated list in endpoints/localai/backend.go: the two drop-in names have always lived on the importer side via AdditionalBackends. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for silero-vad importer Add the SileroVADImporter test fixtures covering metadata, preference overrides, snakers4 + onnx detection, silero_vad.onnx canonical filename, URI fallback, and live HF discovery. Implementation follows in the next commit. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add silero-vad importer Recognise the Silero VAD ONNX packaging: the canonical silero_vad.onnx filename or any ONNX file under the snakers4 owner. Emits a backend: silero-vad config with the vad known_usecase, and attaches the canonical file entry when present so the weights download on import. Registered before the generic importers so the unique-filename signal takes precedence over any downstream tokenizer-based matcher. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for rerankers importer Cover the RerankersImporter contract: interface metadata, preference override, cross-encoder owner detection, case-insensitive 'reranker' substring match (BAAI/bge-reranker, Alibaba-NLP/gte-reranker), URI fallback, and the full-discovery ordering check that a BAAI reranker repo must route to the rerankers importer rather than transformers. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add rerankers importer Recognise reranker repositories — cross-encoder owner or any repo whose name contains 'reranker' (case-insensitive). Emits backend: rerankers with reranking: true and the rerank known_usecase. Registered ahead of sentencetransformers and transformers so reranker repos that happen to ship tokenizer.json or modules.json still route here. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for sentencetransformers importer Cover the SentenceTransformersImporter contract: interface metadata, preference override, modules.json marker file, sentence_bert_config.json marker file, sentence-transformers owner, URI fallback, and the full-discovery ordering check that ensures a sentence-transformers HF URI routes here rather than transformers. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add sentencetransformers importer Recognise sentence-transformers embedding repos by modules.json, sentence_bert_config.json, or the sentence-transformers owner. Emits backend: sentencetransformers with embeddings: true and the embeddings known_usecase. Registered ahead of transformers so ST repos that carry tokenizer.json still route here. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): add failing tests for rfdetr importer Cover the RFDetrImporter contract: interface metadata, preference override, case-insensitive rf-detr and rfdetr substring matches, URI fallback, and negative cases. Implementation follows in the next commit. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(gallery/importers): add rfdetr importer Recognise RF-DETR object-detection repositories by a case-insensitive 'rf-detr' / 'rfdetr' substring in the repo name. Emits backend: rfdetr with the detection known_usecase. Registered ahead of transformers so RF-DETR repos with tokenizer artefacts still route here. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(gallery/importers): surface ErrAmbiguousImport on sentence-similarity misses Add an ambiguity fixture covering the embeddings/rerankers modality. Qdrant/bm25 carries pipeline_tag=sentence-similarity but ships only config.json + stopword .txt files — none of the Batch 5 importers (silero-vad, rerankers, sentencetransformers, rfdetr) or the generic vllm/transformers/llama-cpp/mlx/diffusers importers match. Because the modality is in the ambiguous whitelist, DiscoverModelConfig must surface ErrAmbiguousImport. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(localai/backend): red tests for KnownBackend.Installed flag Extend the /backends/known suite with three failing cases that pin down the forthcoming Installed field: JSON field presence on every entry, flipping to true when an importer-registered backend is also present on disk (and staying false for non-installed pref-only entries), and surfacing system-only backends with empty modality and AutoDetect=false. A small writeFakeSystemBackend helper plants a run.sh under the backends dir so gallery.ListSystemBackends recognises the fixture. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(schema,localai/backend): add Installed flag to KnownBackend Add an Installed bool to schema.KnownBackend and populate it from the /backends/known handler so the React import form can warn users that picking a not-yet-installed backend will trigger an automatic download on submit. Computation: after merging the importer registry, additional backends provider entries and the curated pref-only slice, the handler walks gallery.ListSystemBackends(systemState) and either flips the existing map entry's Installed flag to true (preserving modality / autodetect / description metadata) or inserts a bare {Installed:true} entry for system-only backends the importer layer doesn't know about. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(localai/import_model): structured ambiguous-import response Add red tests covering the extended ambiguity shape the React import form needs: - ImportModelURIEndpoint must return an HTTP 400 body that exposes the detected `modality` (normalised to the importer modality key, e.g. "tts" for pipeline_tag=text-to-speech) and a list of `candidates` (backend names filtered by modality, excluding text-LLM backends). - The importers package must surface a typed AmbiguousImportError so HTTP consumers can read Modality + Candidates without parsing the error string. errors.Is against the existing sentinel keeps working. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(localai/import_model): structured ambiguity response with modality + candidates DiscoverModelConfig now returns a typed AmbiguousImportError that carries the importer modality key, candidate backend names, the original URI, and the raw HF pipeline_tag. Its Is() preserves errors.Is(err, ErrAmbiguousImport) for legacy callers. The importer modality is pre-mapped from the HF pipeline_tag (automatic-speech-recognition → asr, text-to-speech → tts, etc) via PipelineTagToModality — surfaced as an exported helper so downstream consumers can avoid duplicating the table. CandidatesForModality filters the default importer registry plus AdditionalBackendsProvider drop-ins by modality, sorts deterministically, and is the single source of truth used by ImportModelURIEndpoint. ImportModelURIEndpoint now returns HTTP 400 with { error, detail, modality, candidates, hint } when ambiguity fires, letting the React form render a modality-scoped picker inline instead of a generic toast. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(ui/import): manual pick badge + tooltip Red Playwright coverage for the preference-only → manual pick rename: - The Backend dropdown renders a "manual pick" badge on every option whose KnownBackend.auto_detect is false. - The badge carries a title attribute with hover-tooltip copy that explains auto-detect won't route to this backend. - Auto-detectable backends must NOT carry the badge. - The legacy " (preference-only)" suffix is gone from every label. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * ui(import): replace preference-only suffix with manual pick badge SearchableSelect option rows now support an optional badge field — a muted pill rendered to the right of the label with an optional title attribute for native hover tooltips. Plain text so screen readers read it alongside the option name. buildBackendOptions in ImportModel stops appending " (preference-only)" to the label and instead sets badge="manual pick" plus a descriptive tooltip on every option whose auto_detect is false. The Backend help text explains what "manual pick" means so users aren't left wondering about the badge. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(ui/import): inline ambiguity picker Red Playwright coverage for Batch A2 — when the server returns a 400 ambiguity body, the form must render an inline alert instead of a toast, expose one clickable chip per candidate backend, and support both auto-resubmit on pick and silent dismiss. - Mocks /api/models/import-uri with the structured ambiguity body (error, detail, modality, candidates, hint). - On first click of Import, the alert is visible, carries modality-specific copy, and shows a chip per candidate. - Clicking a chip clears the alert, sets the Backend dropdown, and triggers a second POST to /api/models/import-uri. - Dismissing the alert leaves the Backend dropdown on Auto-detect — no implicit backend assignment. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(ui/import): inline ambiguity alert with candidate chips Adds AmbiguityAlert — a soft, info-coloured card rendered above the URI input when the server returns a structured 400 with { modality, candidates }. Message is modality-aware (tts/asr/embeddings/image/ reranker/detection get purpose-written copy, everything else falls back to a generic template). Each candidate is a clickable chip that shows a download icon when /backends/known marks the backend as not yet installed, so users aren't surprised by an implicit install. ImportModel wires the alert to handleSimpleImport's error path: - api.handleResponse now attaches { status, body } to the thrown Error so pages can pattern-match on structured responses instead of string error messages. - handleSimpleImport detects `status === 400 && body.error === 'ambiguous import'` and flips into the inline-picker mode instead of toasting. - Clicking a chip sets prefs.backend and auto-resubmits (passing the picked backend as an override so setPrefs's asynchrony doesn't leak a stale value). - Dismissing clears the alert; changing the URI or the backend also clears it so a stale alert never sticks around. Test fixtures mock GET /backends/known + POST /models/import-uri so the Playwright specs don't depend on real network reachability. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(ui/import): auto-install warning Red Playwright coverage for Batch A3 — when the user picks a backend whose KnownBackend.installed is false, the form must render a muted inline note under the Backend dropdown warning that submitting will download the backend first. Picking an installed backend or leaving Auto-detect selected must keep the note hidden. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(ui/import): auto-install warning under backend dropdown When the user picks a backend whose KnownBackend.installed is false, render a muted inline note under the Backend dropdown's help text warning that submitting will download the backend first. The note lives inside the same form-group so it lines up with the existing hint text; it's hidden when Auto-detect is selected (the selected backend is unknowable at that point) or when the chosen backend is already on disk. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * ui(import): drop redundant section header, adjust icons, rename HF shortcut - Remove the "Import from URI" card-level <h2> — the page title already says "Import New Model" one row up, so the secondary header was duplicating information. - Swap the fa-star on "Common Preferences" for fa-sliders (stars imply favourites/ratings; this is just a preferences block) and move the Custom Preferences fa-sliders-h to fa-plus-circle so the two blocks read as distinct rather than as two sliders. - Rename the HF shortcut from "Search GGUF on HF" → "Browse models on HF" and drop the `search=gguf` filter on the linked URL. The import form now supports ~40 backends; hard-coding GGUF in the copy no longer matches the form's actual reach. - Pure polish — no behaviour change, covered by the existing Batch A Playwright suite. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(ui/import): batch B — simple/power switch, options, tabs, dialog Adds a failing Playwright suite covering the full Batch B surface ahead of implementation: - B1: SimplePowerSwitch segmented control renders, toggles, persists to localStorage across reloads. - B2: Simple-mode Options disclosure is collapsed by default; expanding exposes only Backend, Model Name, Description (no quantizations, mmproj, model type, or custom prefs). - B3: Power mode has Preferences and YAML tabs with a persistent selection across reloads; URI/name/description typed in Simple carry over to Power; YAML tab swaps the primary action to Create. - B4: Switching Power -> Simple with a custom preference set triggers the 3-button confirmation dialog (Keep / Discard / Cancel) with the documented semantics. Tests fail against master — implementation lands in the following commits. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(ui/import): add SimplePowerSwitch segmented control Replaces the previous "Advanced Mode / Simple Mode" toggle button in the page header with a two-segment control that flips between Simple and Power. The control reuses the existing .segmented CSS shared with the Sound page for visual consistency. Mode state is persisted to localStorage under `import-form-mode` so reloads land on the same view (default: simple). The boolean alias `isAdvancedMode` is retained internally to minimise diff — subsequent commits reshape the Simple and Power surfaces independently. Closes B1 from the Batch B Playwright suite. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(ui/import): simple mode collapsible options, power tabs, switch dialog Completes the Batch B surface in a single structural pass so Simple and Power mode can evolve independently: Simple mode - URI input + Ambiguity alert + Import button, plus a collapsible "Options" disclosure that exposes ONLY Backend, Model Name, Description. Quantizations / MMProj / Model Type / Diffusers fields / Custom Preferences are no longer rendered in Simple mode. Power mode - In-page segmented "Preferences · YAML" tab strip. Active tab persists to localStorage under `import-form-power-tab`. - Preferences tab = the full existing preferences + custom prefs panel (no progressive disclosure yet — that's Batch D). - YAML tab = the existing CodeEditor. Primary button reads "Create" here, "Import Model" everywhere else. Switch dialog - Power -> Simple with non-default prefs (advanced pref keys set, any custom-pref key non-empty, or YAML edited away from the template) opens a 3-button dialog: Keep & switch / Discard & switch / Cancel. - Keep preserves all state. Discard resets prefs + customPrefs + YAML to defaults. Cancel leaves the user in Power mode. Page subtitle reflects the current surface (Simple, Power/Preferences, Power/YAML). Estimate banner renders everywhere except Power/YAML. Closes B2/B3/B4 from the Batch B Playwright suite. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(ui/import): expand Options disclosure in Batch A tests Batch B hid the Backend dropdown behind a collapsible Options disclosure in Simple mode. The Batch A tests that exercise the dropdown directly (manual-pick badge, ambiguity chip sets the selected backend, auto- install warning) now click the disclosure toggle before asserting on dropdown contents. Test intent is unchanged. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * ui(import): strip decorative icons from field labels The preference panel had 12 Font Awesome icons decorating field labels (Backend, Model Name, Description, Quantizations, MMProj Quantizations, Model Type, Pipeline Type, Scheduler Type, Enable Parameters, Embeddings, CUDA, plus fa-link on Model URI). Every label screamed equally, flattening the visual hierarchy. Remove them. Keep icons where they carry meaning: page-level section headers, URI format guide entries, primary buttons, the Simple-mode Options disclosure, the ambiguity alert's fa-lightbulb, the auto-install note's fa-download, and the Estimated-requirements banner's fa-memory / fa-microchip / fa-download. No new behaviour, no layout / spacing changes beyond removing the orphaned icon margin. Playwright suite green. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(ui/import): progressive disclosure of preference fields Cover the Batch D visibility matrix for Power > Preferences: Quantizations, MMProj Quantizations, and Model Type each render only for the backends that can consume them, stay visible when the backend is unset, and preserve any value the user already typed when toggled off and back on. Also pin the shrunk Description textarea at rows=2. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(ui/import): progressive disclosure + shorter description textarea Gate Quantizations, MMProj Quantizations, and Model Type in the Power > Preferences tab so each field only renders for the backends that can actually consume it. Backend unset keeps everything visible. Hidden fields' state is preserved (the JSX wrapper is guarded, not the underlying prefs state) so users flipping backends back and forth don't lose input. Also shrink the Description textarea from rows=3 to rows=2 — it's shared between Simple Options and Power Preferences so the change applies to both. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(ui/import): enter-to-submit in Simple mode Red test for Batch F3 — pressing Enter in the URI input must POST /models/import-uri, and Enter in the Description textarea must insert a newline without submitting the form. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(ui/import): enter-to-submit in Simple mode Wrap the Simple-mode URI input + ambiguity alert + Options disclosure in a <form> whose onSubmit calls handleSimpleImport. Pressing Enter in the URI input (or any Simple-mode text input) now submits the import without having to move the mouse to the header button. The Description textarea keeps its native behaviour — Enter inserts a newline. A hidden submit button is included because the visible Import button lives outside the form in the page header; some browsers only fire implicit Enter-submit when the form contains a submit-capable element. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * ui(import,SearchableSelect,components): aria-hidden on decorative icons Every Font Awesome icon in the import form is decorative — its meaning is already conveyed by adjacent visible text. Adding aria-hidden="true" prevents screen readers from announcing the unicode glyph point as content. Covers ImportModel.jsx (all remaining <i> glyphs) and SearchableSelect.jsx (the trigger chevron). AmbiguityAlert and SimplePowerSwitch already set aria-hidden on their icons when the components landed in Batches A and B — no change needed there. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * ui(SearchableSelect): responsive dropdown maxHeight + hover focus guard F2 — replace fixed pixel heights with min(pixel, vh) so the dropdown and its inner scroll region don't overflow short viewports. Outer container: 260px -> min(260px, 60vh); inner listbox: 200px -> min(200px, 50vh). Tall viewports still get the original pixel caps. F5 — short-circuit onMouseEnter when the hovered row is already the focused row. Avoids queueing a setFocusIndex call (and a render) for every mousemove inside the same item — the state would be identical. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * ui(import): aria-label on custom preference rows The Key / Value inputs and trash button in each Custom Preferences row previously relied on placeholder text alone. Placeholders are not accessible names — they vanish on input and screen readers do not announce them consistently. Add row-indexed aria-labels so assistive tech can distinguish "Preference key for row 1" from "row 2", and give the trash button an explicit "Remove this preference" label. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * test(ui/import): modality chip row Red tests for Batch E — a horizontal modality chip row that filters the Backend dropdown by modality. Covers visibility in Simple-mode Options and Power/Preferences (and absence in Power/YAML), filter behaviour, mismatched-backend clearing with toast, ambiguity-alert auto-selection, and radiogroup keyboard navigation. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * feat(ui/import): add ModalityChips component + filter integration Horizontal chip row (Any, Text, Speech, TTS, Image, Embeddings, Rerankers, Detection, VAD) filters the Backend dropdown options to the selected modality. Default is Any — no filter, current behaviour. - New ModalityChips component (radiogroup pattern, roving tabindex, arrow-key navigation, Home/End). - buildBackendOptions now accepts an optional modalityFilter so grouped output is narrowed before rendering. - Chips render inside Simple-mode Options disclosure and Power > Preferences tab. Power > YAML stays unaffected. - Switching the filter drops a mismatched backend selection and surfaces a toast so the auto-clear is visible. - Ambiguity alerts auto-activate the matching chip so users see only relevant backends even if they dismiss the alert. Tightens the Batch E tests' option-matching to the label <span> so the "↵" keybind hint on the focused row doesn't break accessible-name lookups. Assisted-by: Claude:claude-opus-4-7[1m] [Agent] * fix(ui/import): rename Power to Advanced + stop URI-formats toggle from submitting form The "Supported URI Formats" disclosure button inside the Simple-mode form lacked an explicit type attribute, so it defaulted to type="submit". Every click triggered the form's onSubmit and surfaced the empty-URI validation toast ("Please enter a model URI"). Marking it type="button" lets it behave as a pure toggle. While here, rename the user-visible "Power" label to "Advanced" in the mode switch (button text + tooltip) and the Power-mode tab's aria-label, matching the term users actually expect. The internal mode key stays 'power' so tests, localStorage, and data-testid selectors are untouched. Assisted-by: Claude:claude-opus-4-7 * fix(system): fall back to cpu when meta backend lacks default capability Meta backends like vllm and sglang enumerate concrete variants for nvidia/amd/intel/cpu but omit a default: catch-all entry. On a no-GPU host the reported capability is "default", so the previous Capability() returned "default" unconditionally on a miss — IsCompatibleWith then saw no "default" key and filtered the meta out of AvailableBackends. The import flow's auto-install step then failed with "no backend found with name <meta>", contradicting the UI's promise that the backend would be downloaded on demand. Try the explicit "default" key first, then fall back to "cpu" before giving up. vllm now resolves to cpu-vllm on CPU-only Linux without touching the gallery YAML. Assisted-by: Claude:claude-opus-4-7
2026-04-22 20:42:37 +00:00
It("should populate PipelineTag and LibraryName on ModelDetails", func() {
// Sentence-transformers/all-MiniLM-L6-v2 is a public, stable repo:
// pipeline_tag: sentence-similarity, library_name: sentence-transformers.
// This exercises the /api/models/{repo} metadata fetch layered on top
// of ListFiles in GetModelDetails.
realClient := hfapi.NewClient()
details, err := realClient.GetModelDetails("sentence-transformers/all-MiniLM-L6-v2")
Expect(err).ToNot(HaveOccurred())
Expect(details).ToNot(BeNil())
Expect(details.PipelineTag).To(Equal("sentence-similarity"))
Expect(details.LibraryName).To(Equal("sentence-transformers"))
})
})
Context("when model metadata endpoint returns fields", func() {
It("should populate PipelineTag and LibraryName from /api/models/{repo}", func() {
// Serve both the tree listing and the single-model metadata endpoint.
mockFilesResponse := `[
{"type": "file", "path": "config.json", "size": 100, "oid": "cfg1"}
]`
mockModelResponse := `{
"modelId": "fake/model",
"pipeline_tag": "text-to-speech",
"library_name": "transformers"
}`
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/tree/main"):
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockFilesResponse))
case strings.HasSuffix(r.URL.Path, "/api/models/fake/model"):
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockModelResponse))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
client.SetBaseURL(server.URL + "/api/models")
details, err := client.GetModelDetails("fake/model")
Expect(err).ToNot(HaveOccurred())
Expect(details).ToNot(BeNil())
Expect(details.PipelineTag).To(Equal("text-to-speech"))
Expect(details.LibraryName).To(Equal("transformers"))
})
It("should tolerate a failing metadata endpoint and still return file details", func() {
mockFilesResponse := `[
{"type": "file", "path": "config.json", "size": 100, "oid": "cfg1"}
]`
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/tree/main"):
w.WriteHeader(http.StatusOK)
w.Write([]byte(mockFilesResponse))
default:
// Simulate metadata endpoint outage.
w.WriteHeader(http.StatusInternalServerError)
}
}))
client.SetBaseURL(server.URL + "/api/models")
details, err := client.GetModelDetails("fake/model")
Expect(err).ToNot(HaveOccurred())
Expect(details).ToNot(BeNil())
Expect(details.Files).To(HaveLen(1))
Expect(details.PipelineTag).To(BeEmpty())
Expect(details.LibraryName).To(BeEmpty())
})
})
})
// findFileByPath is a helper function to find a file by its path in a slice of FileInfo
func findFileByPath(files []hfapi.FileInfo, path string) *hfapi.FileInfo {
for i := range files {
if files[i].Path == path {
return &files[i]
}
}
return nil
}