diff --git a/core/gallery/storage.go b/core/gallery/storage.go new file mode 100644 index 000000000..5e1d06fe6 --- /dev/null +++ b/core/gallery/storage.go @@ -0,0 +1,200 @@ +package gallery + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/mudler/LocalAI/pkg/system" +) + +// StorageFile is one referenced path under the models directory with the +// model configs referencing it. Missing marks a reference whose file is +// not on disk (a download that never finished, or removed by hand); +// len(Models) > 1 is what the UI renders as "shared". +type StorageFile struct { + // Path is relative to the models directory. + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + Missing bool `json:"missing,omitempty"` + Models []string `json:"models"` +} + +// ModelStorage is the disk footprint of one model config. SharedBytes is +// the portion of SizeBytes in files other configs reference too: deleting +// this model frees SizeBytes minus SharedBytes. Missing lists referenced +// paths with no file behind them — surfaced as a warning in the UI. +type ModelStorage struct { + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + SharedBytes int64 `json:"shared_bytes"` + Files []string `json:"files"` + Missing []string `json:"missing,omitempty"` +} + +// StorageIndex is the storage report for the models directory. TotalBytes +// deduplicates shared files, so it is the actual on-disk payload of the +// installed configs rather than the sum of the per-model sizes. +type StorageIndex struct { + Models []ModelStorage `json:"models"` + Files []StorageFile `json:"files"` + TotalBytes int64 `json:"total_bytes"` + // Errors carries per-config failures (malformed YAML, path escapes), + // reported rather than fatal so one broken config cannot blank the + // whole report. + Errors []string `json:"errors,omitempty"` +} + +// BuildStorageIndex attributes every referenced file to the model configs +// referencing it, using the same resolution the delete path trusts +// (listModelFiles: the config's model/mmproj references plus the gallery +// sidecar's file list, including the sidecar itself). Model references +// are confined to the models path by construction — resolution always +// joins onto it — so the index is complete for installed configs. +func BuildStorageIndex(systemState *system.SystemState) (*StorageIndex, error) { + base := systemState.Model.ModelsPath + entries, err := os.ReadDir(base) + if err != nil { + return nil, err + } + + type fileInfo struct { + size int64 + missing bool + models []string + } + files := map[string]*fileInfo{} + index := &StorageIndex{Models: []ModelStorage{}, Files: []StorageFile{}} + + var modelNames []string + for _, e := range entries { + if e.IsDir() { + continue + } + n := e.Name() + if strings.HasPrefix(n, "._gallery_") { + continue + } + if !strings.HasSuffix(n, ".yaml") && !strings.HasSuffix(n, ".yml") { + continue + } + modelNames = append(modelNames, strings.TrimSuffix(strings.TrimSuffix(n, ".yaml"), ".yml")) + } + sort.Strings(modelNames) + + for _, name := range modelNames { + refs, err := listModelFiles(systemState, name) + if err != nil { + index.Errors = append(index.Errors, fmt.Sprintf("%s: %v", name, err)) + continue + } + ms := ModelStorage{Name: name, Files: []string{}} + seen := map[string]bool{} + for _, f := range refs { + if seen[f] { + continue + } + seen[f] = true + // listModelFiles names the ._gallery_ sidecar unconditionally + // (deletion must remove it when present). An ABSENT sidecar is + // the normal state of a hand-written config, not a broken + // reference — only a present one counts as payload. + if strings.HasPrefix(filepath.Base(f), "._gallery_") { + if _, err := os.Stat(f); err != nil { + continue + } + } + fi, ok := files[f] + if !ok { + fi = &fileInfo{} + if size, err := pathSize(f); err != nil { + fi.missing = true + } else { + fi.size = size + } + files[f] = fi + } + fi.models = append(fi.models, name) + rel, err := filepath.Rel(base, f) + if err != nil { + rel = f + } + if fi.missing { + ms.Missing = append(ms.Missing, rel) + continue + } + ms.Files = append(ms.Files, rel) + ms.SizeBytes += fi.size + } + sort.Strings(ms.Files) + sort.Strings(ms.Missing) + index.Models = append(index.Models, ms) + } + + // Shared bytes need the final refcounts, hence the second pass. + for i := range index.Models { + m := &index.Models[i] + for _, rel := range m.Files { + if fi := files[filepath.Join(base, rel)]; fi != nil && len(fi.models) > 1 { + m.SharedBytes += fi.size + } + } + } + + for p, fi := range files { + rel, err := filepath.Rel(base, p) + if err != nil { + rel = p + } + sort.Strings(fi.models) + index.Files = append(index.Files, StorageFile{ + Path: rel, SizeBytes: fi.size, Missing: fi.missing, Models: fi.models, + }) + index.TotalBytes += fi.size + } + + sort.Slice(index.Models, func(i, j int) bool { + if index.Models[i].SizeBytes != index.Models[j].SizeBytes { + return index.Models[i].SizeBytes > index.Models[j].SizeBytes + } + return index.Models[i].Name < index.Models[j].Name + }) + sort.Slice(index.Files, func(i, j int) bool { + if index.Files[i].SizeBytes != index.Files[j].SizeBytes { + return index.Files[i].SizeBytes > index.Files[j].SizeBytes + } + return index.Files[i].Path < index.Files[j].Path + }) + + return index, nil +} + +// pathSize sizes a referenced path: a plain stat for files, a walk for +// artifact snapshot directories (multi-file models resolve to a directory). +func pathSize(p string) (int64, error) { + st, err := os.Stat(p) + if err != nil { + return 0, err + } + if !st.IsDir() { + return st.Size(), nil + } + var total int64 + err = filepath.WalkDir(p, func(_ string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type().IsRegular() { + info, err := d.Info() + if err != nil { + return err + } + total += info.Size() + } + return nil + }) + return total, err +} diff --git a/core/gallery/storage_test.go b/core/gallery/storage_test.go new file mode 100644 index 000000000..e823560b1 --- /dev/null +++ b/core/gallery/storage_test.go @@ -0,0 +1,107 @@ +package gallery_test + +import ( + "os" + "path/filepath" + + . "github.com/mudler/LocalAI/core/gallery" + "github.com/mudler/LocalAI/pkg/system" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("StorageIndex", func() { + var base string + var state *system.SystemState + + write := func(name, content string) { + Expect(os.WriteFile(filepath.Join(base, name), []byte(content), 0o644)).To(Succeed()) + } + + // The sidecar fixture is 54 bytes; its size shows up in alpha's total + // because deletion counts it too — the index mirrors what delete frees. + const sidecar = "files:\n- filename: alpha.gguf\n- filename: shared.gguf\n" + + BeforeEach(func() { + base = GinkgoT().TempDir() + var err error + state, err = system.GetSystemState(system.WithModelPath(base)) + Expect(err).ToNot(HaveOccurred()) + + // alpha: own weight plus the shared file via its gallery sidecar. + write("alpha.yaml", "name: alpha\nparameters:\n model: alpha.gguf\n") + write("._gallery_alpha.yaml", sidecar) + // beta: a hand-written config (no sidecar) reusing alpha's file. + write("beta.yaml", "name: beta\nparameters:\n model: shared.gguf\n") + + write("alpha.gguf", "aaaa") // 4 bytes + write("shared.gguf", "ssssssss") // 8 bytes + write("unreferenced.bin", "uu") // orphan detection is out of scope + }) + + It("attributes files to every config referencing them, sidecar included", func() { + idx, err := BuildStorageIndex(state) + Expect(err).ToNot(HaveOccurred()) + + byName := map[string]ModelStorage{} + for _, m := range idx.Models { + byName[m.Name] = m + } + Expect(byName).To(HaveLen(2)) + Expect(byName["alpha"].SizeBytes).To(Equal(int64(4 + 8 + len(sidecar)))) + Expect(byName["alpha"].SharedBytes).To(Equal(int64(8))) + Expect(byName["alpha"].Missing).To(BeEmpty()) + Expect(byName["beta"].SizeBytes).To(Equal(int64(8))) + Expect(byName["beta"].SharedBytes).To(Equal(int64(8))) + // beta has no sidecar on disk: its absence is normal, not a + // broken reference. + Expect(byName["beta"].Missing).To(BeEmpty()) + + byPath := map[string]StorageFile{} + for _, f := range idx.Files { + byPath[f.Path] = f + } + Expect(byPath["shared.gguf"].Models).To(Equal([]string{"alpha", "beta"})) + Expect(byPath["alpha.gguf"].Models).To(Equal([]string{"alpha"})) + Expect(byPath["._gallery_alpha.yaml"].SizeBytes).To(Equal(int64(len(sidecar)))) + }) + + It("deduplicates shared files in the total and ignores unreferenced ones", func() { + idx, err := BuildStorageIndex(state) + Expect(err).ToNot(HaveOccurred()) + Expect(idx.TotalBytes).To(Equal(int64(4 + 8 + len(sidecar)))) + for _, f := range idx.Files { + Expect(f.Path).ToNot(Equal("unreferenced.bin")) + } + }) + + It("reports a reference without a file as missing, per model and per file", func() { + write("gamma.yaml", "name: gamma\nparameters:\n model: never-downloaded.gguf\n") + idx, err := BuildStorageIndex(state) + Expect(err).ToNot(HaveOccurred()) + + byName := map[string]ModelStorage{} + for _, m := range idx.Models { + byName[m.Name] = m + } + Expect(byName["gamma"].Missing).To(Equal([]string{"never-downloaded.gguf"})) + Expect(byName["gamma"].SizeBytes).To(BeZero()) + + byPath := map[string]StorageFile{} + for _, f := range idx.Files { + byPath[f.Path] = f + } + Expect(byPath["never-downloaded.gguf"].Missing).To(BeTrue()) + Expect(byPath["never-downloaded.gguf"].SizeBytes).To(BeZero()) + Expect(byPath["never-downloaded.gguf"].Models).To(Equal([]string{"gamma"})) + }) + + It("reports a broken config instead of failing the whole index", func() { + write("broken.yaml", "::: not yaml :::\n\t") + idx, err := BuildStorageIndex(state) + Expect(err).ToNot(HaveOccurred()) + Expect(idx.Errors).To(HaveLen(1)) + Expect(idx.Errors[0]).To(ContainSubstring("broken")) + Expect(idx.Models).To(HaveLen(2)) + }) +}) diff --git a/core/http/endpoints/localai/model_storage.go b/core/http/endpoints/localai/model_storage.go new file mode 100644 index 000000000..8fd2b701f --- /dev/null +++ b/core/http/endpoints/localai/model_storage.go @@ -0,0 +1,28 @@ +package localai + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/gallery" +) + +// ModelStorageEndpoint reports the on-disk footprint of the installed +// model configs: per-model sizes with the shared portion split out, the +// file-to-models relation, and references whose files are missing. +// @Summary Report model storage usage on disk +// @Description Per-model disk usage, shared-file relations and missing references under the models path +// @Tags models +// @Produce json +// @Success 200 {object} gallery.StorageIndex "storage report" +// @Router /api/models/storage [get] +func ModelStorageEndpoint(appConfig *config.ApplicationConfig) echo.HandlerFunc { + return func(c echo.Context) error { + idx, err := gallery.BuildStorageIndex(appConfig.SystemState) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()}) + } + return c.JSON(http.StatusOK, idx) + } +} diff --git a/core/http/react-ui/public/locales/en/models.json b/core/http/react-ui/public/locales/en/models.json index f60dc3051..e6cb25e09 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -10,7 +10,9 @@ "backToAll": "All installed models", "eyebrow": "Installed on this host", "summary": "{{count}} models ready to serve.", - "summaryHint": "Select a model to inspect its runtime state and controls." + "summaryHint": "Select a model to inspect its runtime state and controls.", + "storageSummary": "{{total}} on disk \u00b7 {{shared}} shared across models", + "storageMissing": "{{count}} missing references" }, "states": { "running": "Running", "idle": "Idle", "disabled": "Disabled", "working": "Working…" }, "actions": { @@ -39,6 +41,10 @@ "description": "Description", "noDescription": "No gallery description is available for this model.", "license": "License", "tags": "Tags", "links": "Links", "distributed": "Distributed", "source": "Source", "files": "Files", "fileCount": "{{count}} file", "fileCount_other": "{{count}} files", + "size": "Size on disk", "sharedBytes": "{{size}} shared with other models", + "storageFiles": "Files on disk", + "missingFiles": "Missing files", + "missingCount": "{{count}} referenced file missing", "missingCount_other": "{{count}} referenced files missing", "adopted": "Adopted", "adoptedHint": "Discovered on a worker but not configured locally. Persist the config to make it permanent.", "alias": "alias → {{target}}", "aliasTitle": "Alias → {{target}}", "chain": "chain → {{target}}", "chainTitle": "Failover chain → {{target}}" @@ -57,6 +63,12 @@ "title": "Models", "subtitle": "Browse and install AI models from the gallery", "models": "Models", + "storage": { + "searchPlaceholder": "Filter by file or model\u2026", + "columns": { "file": "File", "size": "Size", "status": "Status", "models": "Used by" }, + "status": { "missing": "missing", "shared": "shared by {{count}}" }, + "empty": "No referenced files." + }, "recommended": { "title": "Recommended for your hardware", "cpuNote": "No GPU detected - small models that stay responsive on CPU.", diff --git a/core/http/react-ui/src/pages/InstalledModels.jsx b/core/http/react-ui/src/pages/InstalledModels.jsx index fce53a124..59db2f790 100644 --- a/core/http/react-ui/src/pages/InstalledModels.jsx +++ b/core/http/react-ui/src/pages/InstalledModels.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { fromState } from '../utils/editorNav' @@ -16,6 +16,7 @@ import { useGalleryEnrichment } from '../hooks/useGalleryEnrichment' import { useOperations } from '../hooks/useOperations' import useFailoverChains from '../hooks/useFailoverChains' import { backendControlApi, modelsApi, nodesApi, systemApi } from '../utils/api' +import { formatBytes } from '../utils/format' import { renderMarkdown, stripMarkdown } from '../utils/markdown' import { safeHref } from '../utils/url' import { @@ -110,6 +111,24 @@ export default function InstalledModels({ const { operations } = useOperations() const { byName: failoverChains } = useFailoverChains() const [loadedModelIds, setLoadedModelIds] = useState(() => new Set()) + // Admin-only storage report; a 403 for non-admins just hides the size + // information, it never breaks the page. + const [storage, setStorage] = useState(null) + const storageByName = useMemo(() => { + const map = {} + for (const m of storage?.models || []) map[m.name] = m + return map + }, [storage]) + const storageSummary = useMemo(() => { + if (!storage) return null + let shared = 0 + let missing = 0 + for (const f of storage.files || []) { + if (f.missing) missing++ + else if ((f.models || []).length > 1) shared += f.size_bytes + } + return { total: storage.total_bytes, shared, missing } + }, [storage]) const [aliasTargets, setAliasTargets] = useState({}) const [distributedMode, setDistributedMode] = useState(false) const [pendingActions, setPendingActions] = useState(() => new Set()) @@ -142,6 +161,7 @@ export default function InstalledModels({ useEffect(() => { fetchLoadedModels() fetchAliases() + modelsApi.getStorage().then(setStorage).catch(() => setStorage(null)) nodesApi.list().then(() => setDistributedMode(true)).catch(() => setDistributedMode(false)) }, [fetchAliases, fetchLoadedModels]) @@ -363,6 +383,16 @@ export default function InstalledModels({ tone: running ? 'ok' : undefined, }, { label: t('lifecycle.detail.backend'), value: selectedModel.backend || t('lifecycle.detail.auto') }, + storageByName[selectedModel.id] + ? { label: t('lifecycle.detail.size'), value: formatBytes(storageByName[selectedModel.id].size_bytes) } + : null, + storageByName[selectedModel.id]?.missing?.length + ? { + label: t('lifecycle.detail.missingFiles'), + value: t('lifecycle.detail.missingCount', { count: storageByName[selectedModel.id].missing.length }), + tone: 'warn', + } + : null, selectedModel.pinned ? { label: t('lifecycle.detail.pinned'), value: t('lifecycle.detail.yes'), tone: 'warn' } : null, @@ -468,6 +498,9 @@ export default function InstalledModels({ (f.models || []).includes(selectedModel.id))} + onSelectModel={onSelect} distributedMode={distributedMode} t={t} /> @@ -480,6 +513,9 @@ export default function InstalledModels({

{t('lifecycle.installed.summary', { count: models.length })}

{t('lifecycle.installed.summaryHint')}

+ {storage?.files?.length > 0 && ( + + )} ) @@ -511,6 +547,19 @@ export default function InstalledModels({ )} + {storageSummary && ( +
+