diff --git a/core/services/galleryop/list_models.go b/core/services/galleryop/list_models.go index 840d91f6b..47c2d91e5 100644 --- a/core/services/galleryop/list_models.go +++ b/core/services/galleryop/list_models.go @@ -16,6 +16,14 @@ const ( func ListModels(bcl *config.ModelConfigLoader, ml *model.ModelLoader, filter config.ModelConfigFilterFn, looseFilePolicy LooseFilePolicy) ([]string, error) { + // Callers (e.g. the Ollama /api/tags handler) pass nil to mean "no + // filtering". Without this guard the loose-file loop below dereferences + // filter and panics, which Echo surfaces to clients as a dropped + // connection (see issue #9817). + if filter == nil { + filter = config.NoFilterFn + } + skipMap := map[string]struct{}{} dataModels := []string{} diff --git a/core/services/galleryop/list_models_test.go b/core/services/galleryop/list_models_test.go new file mode 100644 index 000000000..99e6e11de --- /dev/null +++ b/core/services/galleryop/list_models_test.go @@ -0,0 +1,64 @@ +package galleryop_test + +import ( + "os" + "path/filepath" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/services/galleryop" + "github.com/mudler/LocalAI/pkg/model" + "github.com/mudler/LocalAI/pkg/system" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Regression test for issue #9817: the Ollama /api/tags handler calls +// ListModels with a nil filter, which used to panic as soon as a loose file +// existed under ModelsPath. The panic surfaced to Ollama clients (e.g. Home +// Assistant) as "Server disconnected without sending a response". +var _ = Describe("ListModels", func() { + var ( + tempDir string + bcl *config.ModelConfigLoader + ml *model.ModelLoader + systemState *system.SystemState + ) + + BeforeEach(func() { + var err error + tempDir, err = os.MkdirTemp("", "list-models-test-*") + Expect(err).NotTo(HaveOccurred()) + + systemState, err = system.GetSystemState(system.WithModelPath(tempDir)) + Expect(err).NotTo(HaveOccurred()) + ml = model.NewModelLoader(systemState) + bcl = config.NewModelConfigLoader(tempDir) + }) + + AfterEach(func() { + os.RemoveAll(tempDir) + }) + + It("does not panic with a nil filter when loose files exist", func() { + // ListFilesInModelPath skips well-known weight-file extensions + // (.gguf, .bin, ...) so use an extension-less file to ensure the + // filter path is exercised. + Expect(os.WriteFile(filepath.Join(tempDir, "loose-model"), []byte("x"), 0o644)).To(Succeed()) + + var names []string + var err error + Expect(func() { + names, err = galleryop.ListModels(bcl, ml, nil, galleryop.SKIP_IF_CONFIGURED) + }).ToNot(Panic()) + Expect(err).ToNot(HaveOccurred()) + Expect(names).To(ContainElement("loose-model")) + }) + + It("does not panic with a nil filter when ModelsPath is empty", func() { + Expect(func() { + _, err := galleryop.ListModels(bcl, ml, nil, galleryop.SKIP_IF_CONFIGURED) + Expect(err).ToNot(HaveOccurred()) + }).ToNot(Panic()) + }) +})