mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-17 00:40:52 -04:00
* feat(version): include OS and arch in the outbound User-Agent
Registries and galleries already receive LocalAI/<version>; adding the
platform follows ordinary client convention and discloses nothing a
registry cannot infer from the manifest it is asked for.
Updates the User-Agent note in docs/content/getting-started/models.md,
which documented the old format.
Assisted-by: Claude:claude-opus-5 [go vet] [go test]
* feat(downloader): identify LocalAI on outbound requests
pkg/oci has always sent a User-Agent; the downloader sent none, so gallery
reads, model-file downloads, resume probes, content-length probes and the
HuggingFace safety scan all went out as a bare Go HTTP client, unattributable
to LocalAI by the hosts serving them.
HuggingFaceScan moves off the client's Get shorthand to an explicit request
for the same reason — the shorthand gives no place to hang a header.
Extends the User-Agent note in docs/content/getting-started/models.md, which
claimed the header was sent only to Ollama and OCI registries.
Assisted-by: Claude:claude-opus-5 [go vet] [go test]
* feat(gallery): add a mirrors list to gallery configuration
Mirrors are an availability fallback, tried in order only after the primary
URL fails. omitempty keeps existing configurations byte-identical.
The slice makes config.Gallery non-comparable with ==, which broke the two
slices.Equal callers in the runtime settings registry. Replace them with an
explicit Gallery.Equal / GalleriesEqual so a gallery list that differs from
the baseline only by its mirrors still counts as env/CLI-set. Equal compares
the Verification block by value; == compared it by pointer identity, which
called two structurally identical policies different.
Assisted-by: Claude:claude-opus-5 [go vet] [go test]
* fix(downloader): treat an HTTP error status as a failed read
ReadWithCallback handed the response body to its callback whatever the
status was, so a 404 page or a 502 from a CDN arrived as if it were a
gallery index or a model config: it parsed to nothing, got cached for an
hour, and no caller could tell the source had been down. DownloadFile has
always checked the status; this path never did.
Mirror fallback depends on it — a source that answers with an error page
has to count as unreachable, or the next candidate is never tried.
Assisted-by: Claude:claude-opus-5 [go vet] [go test]
* feat(gallery): fall back to mirrors when the primary source fails
Candidates are tried primary-first with a bounded timeout each, and a
source that just failed is skipped for a cooldown so a dead host is not
re-dialled on every listing. When every candidate is in cooldown they are
all tried anyway: refusing to serve a gallery we might be able to reach is
worse than one slow request.
The one-hour index cache is untouched and stays keyed on the gallery's own
identity, so a mirror-served fetch fills the entry the primary would have.
No SSRF validation is applied to the candidates. validateGalleryConfigURL
guards GetGalleryConfigFromURL because that URL arrives in a request body;
mirrors come from the operator's gallery configuration, the same place the
primary has always come from, and the index fetch has never validated the
primary. Validating mirrors while the primary goes unchecked would buy
nothing and would break the deployment mirrors exist for — an index served
from a host on the LAN.
Assisted-by: Claude:claude-opus-5 [go vet] [go test]
* fix(gallery): loosen the mirror fetch timeout and stop blaming the caller
The downloader only ever bounded response headers, never the body, so the
per-attempt deadline added with mirror fallback was the first whole-transfer
timeout this path has had. At 30s the default 2.2 MB index demanded ~75 KB/s
sustained: a rural-DSL, mobile or satellite user who used to wait 60s and
succeed would now fail, and then eat a 10-minute cooldown on a source that
was perfectly healthy. Raised to 120s (~19 KB/s), which no link that could
go on to download a model will miss, and made it a var so a test can shorten
it and prove a hanging candidate is actually abandoned.
Caller cancellation is no longer recorded as a failure of the source.
Unreachable today since getGalleryElements passes context.Background(), but
once a request context is wired through, a browser disconnect would have
blackholed every candidate for ten minutes over something the sources had
no part in.
Also document that mirrors do not cover a .ref gallery URL: the reference is
resolved before mirrors are considered, so a .ref that cannot be fetched
fails the gallery outright. Routing .ref resolution through the candidate
list needs a per-candidate resolve-and-fetch and a decision about cache
identity, which is more than this change should carry.
Assisted-by: Claude:claude-opus-5 [go vet] [go test]
* feat(gallery): serve the last known good index when everything is offline
A successful fetch is cached alongside the models directory and served when
no source is reachable, so an offline or airgapped machine can still list
its gallery. Entries may be stale in that state, and the fallback is logged.
The copy is deliberately kept out of the models directory, where a <name>.yaml
file is read as an installed model's configuration, and is named after a digest
of the gallery URL so the model and backend galleries cannot collide. Writing
it is best effort: a read-only or full disk must not fail a fetch that
otherwise succeeded.
Also corrects the mirror scheme list in the docs: the HuggingFace prefixes are
huggingface://, hf:// and hf.co/, not huggingface:.
Assisted-by: Claude:claude-opus-5 [go vet] [go test]
* fix(gallery): only cache a response that is really a gallery index
The last known good copy was written on any 2xx, before anything looked
at the bytes: the parse only happens later, in getGalleryElements. A
captive portal, a corporate proxy or a CDN error page all answer HTTP 200
with HTML, so any of them could overwrite a good copy. The listing fails
then and there, and the next offline start — the one case this cache
exists for — serves the interception page instead of the gallery it
already had.
Probe the body before persisting it: unmarshal into a []any and keep the
older copy unless the result is a non-empty sequence. An empty document
is rejected too. It parses fine, so a parse-only check would still let a
blank response replace a populated index with one that lists nothing,
which from the user's side is the same outage; and an empty index is
worth nothing offline, so there is no case where caching it beats keeping
what came before. The live body is still returned to the caller — the
probe gates persistence only, and getGalleryElements remains the thing
that reports a real parse failure.
Also in this pass:
- The empty-basePath guard only caught exact "". galleryCachePath(".")
and galleryCachePath("models") still resolved the cache sibling against
the process working directory, which is what the guard was written to
prevent. Reject any non-absolute base.
- The docs claimed the offline cache "applies to every gallery, with or
without mirrors". Not true for a .ref URL: the reference is resolved
before the cache is consulted, so a .ref gallery fails offline even
after a successful earlier fetch, and the cache file it writes can
never be read. Extend the .ref warning and qualify the sentence.
- pkg/oci's UserAgent comment never mentioned the platform component
added earlier on this branch.
- resetGalleryFailures and expireGalleryFailure had no non-test callers;
move them into the test file.
- The all-candidates-failed error reported len(attempt), so a three
mirror gallery with two sources in cooldown said "all 1 source(s)
failed" — which reads as a misconfiguration. Report how many were
configured and how many were skipped.
- Give the package's tests their own TMPDIR. The cache is a sibling of
the models directory, which is right in production, but specs that
build a models directory directly under /tmp made the sibling resolve
to /tmp/cache and left it behind after every run.
Assisted-by: Claude:claude-opus-5 [go vet] [go test]
* fix(gallery): convert the new tests to Ginkgo and clear the lint gate
.agents/coding-style.md requires Ginkgo v2 + Gomega for every Go test and
has forbidigo enforce it; the stdlib-style tests still in the tree are tech
debt, not a pattern. Every test file this branch added was written in the
forbidden style, which is what turned CI red.
Convert all five of them. internal had no suite bootstrap, so add one;
core/config, core/gallery and pkg/downloader already have theirs and are
reused, so no package mixes styles. pkg/downloader/useragent_test.go and
read_status_test.go were not in CI's forbidigo list but used the same
forbidden calls, so they are converted too.
The one conversion with a trap in it is core/gallery. Go's t.TempDir()
yields $TMPDIR/<TestName>NNNN/001, so the gallery cache — a sibling of the
models directory — was isolated per test. GinkgoT().TempDir() yields a flat
$TMPDIR/ginkgoNNNN, which would put every spec's cache in one shared
directory and break the specs that count files in it. tempModelsDir()
restores the original isolation.
Also make the deliberate cleanup-path ignores explicit with `_ =`, drop the
gallery cache directory to 0750 (nothing outside the server's own user and
group reads it), and justify the cache read with a #nosec G304 comment in
the form already used elsewhere in the tree: the path is a hex sha256 under
a fixed directory with a non-absolute base already rejected, so no
caller-supplied text reaches it.
Re-ran the mutations these specs were verified against — dropping the
platform suffix from UserAgent, making Gallery.Equal ignore Mirrors and
ignore Name, removing persistGalleryIndex's validity probe, removing the
!filepath.IsAbs guard, not skipping a cooled-down candidate, and dropping
the per-attempt timeout. All seven still fail the converted specs.
Assisted-by: Claude:claude-opus-5 [go vet] [go test] [golangci-lint] [gosec]
---------
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
275 lines
11 KiB
Go
275 lines
11 KiB
Go
package gallery
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/pkg/downloader"
|
|
"github.com/mudler/LocalAI/pkg/xsync"
|
|
"github.com/mudler/xlog"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// galleryFetchTimeout bounds a single candidate attempt. GitHub's raw endpoint
|
|
// degrades by getting slow far more often than by returning an error, so the
|
|
// timeout — not the mirror list — is what actually gets a user to a working
|
|
// gallery on a bad day.
|
|
//
|
|
// It is deliberately far longer than a healthy fetch needs. The downloader only
|
|
// ever bounded the response headers, never the body, so this is the first
|
|
// whole-transfer deadline this path has had: too tight a value would fail slow
|
|
// links that work today and then park a perfectly healthy source in cooldown
|
|
// for ten minutes. The default index is ~2.2 MB, so 120s tolerates a sustained
|
|
// ~19 KB/s — below any link that could go on to install a model.
|
|
//
|
|
// A var rather than a const so tests can shorten it.
|
|
var galleryFetchTimeout = 120 * time.Second
|
|
|
|
// galleryFailureCooldown keeps a candidate that just failed out of the rotation
|
|
// for a while. Without it, every gallery listing pays the full timeout against
|
|
// a dead host before reaching a mirror that works.
|
|
const galleryFailureCooldown = 10 * time.Minute
|
|
|
|
// galleryFailures records when each candidate URL last failed. It is
|
|
// package-level and shared by every gallery: the point is that a host which is
|
|
// down stays skipped across listings, and the URL is what identifies it.
|
|
var galleryFailures = xsync.NewSyncedMap[string, time.Time]()
|
|
|
|
// galleryCandidates returns the URLs to try, primary first. Empty and repeated
|
|
// entries are dropped so a copy-pasted config cannot make us dial the same
|
|
// dead host three times.
|
|
//
|
|
// Deliberately no SSRF validation here. validateGalleryConfigURL guards
|
|
// GetGalleryConfigFromURL because that URL arrives in a request body; these
|
|
// come from the operator's own gallery configuration (LOCALAI_GALLERIES or the
|
|
// admin-gated POST /api/settings), the same place the primary URL has always
|
|
// come from, and the index fetch has never validated the primary. A mirror is
|
|
// no more privileged than the URL it backs up, so validating mirrors while the
|
|
// primary goes unchecked would buy nothing and would break the deployment
|
|
// mirrors exist for: an index served from a host on the LAN. file:// mirrors
|
|
// remain confined to the models directory by the downloader's basePath check.
|
|
func galleryCandidates(g config.Gallery) []string {
|
|
seen := make(map[string]struct{}, len(g.Mirrors)+1)
|
|
out := make([]string, 0, len(g.Mirrors)+1)
|
|
|
|
for _, candidate := range append([]string{g.URL}, g.Mirrors...) {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[candidate]; dup {
|
|
continue
|
|
}
|
|
seen[candidate] = struct{}{}
|
|
out = append(out, candidate)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// inCooldown reports whether a candidate failed recently enough to skip.
|
|
//
|
|
// Exists and Get take the lock separately, so a concurrent Delete between them
|
|
// yields the zero time and reads as "not in cooldown". That is the harmless
|
|
// direction: the cost is one extra dial, never a skipped source.
|
|
func inCooldown(url string) bool {
|
|
if !galleryFailures.Exists(url) {
|
|
return false
|
|
}
|
|
failedAt := galleryFailures.Get(url)
|
|
if failedAt.IsZero() || time.Since(failedAt) >= galleryFailureCooldown {
|
|
galleryFailures.Delete(url)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// galleryCachePath is where the last known good copy of an index lives.
|
|
//
|
|
// Deliberately not inside basePath: getGalleryElements' caller treats every
|
|
// <name>.yaml in the models directory as an installed model config, so a cached
|
|
// index there would be misread as a model. The sibling cache directory follows
|
|
// the precedent in core/services/worker/file_staging.go. The name is a digest
|
|
// of the gallery URL so the model and the backend gallery — often fetched with
|
|
// sibling base paths — cannot overwrite each other.
|
|
//
|
|
// A non-absolute basePath yields no path at all: "", "." and "models" all
|
|
// resolve the sibling against the process' working directory, which is not
|
|
// somewhere LocalAI should be dropping files. Only an absolute models
|
|
// directory names a location we can reason about.
|
|
func galleryCachePath(basePath, url string) string {
|
|
if !filepath.IsAbs(basePath) {
|
|
return ""
|
|
}
|
|
sum := sha256.Sum256([]byte(url))
|
|
return filepath.Join(basePath, "..", "cache", "gallery", hex.EncodeToString(sum[:])+".yaml")
|
|
}
|
|
|
|
// isUsableGalleryIndex reports whether body is worth keeping as the last known
|
|
// good copy.
|
|
//
|
|
// HTTP 200 does not mean "index": a captive portal, a corporate proxy or a CDN
|
|
// error page all answer 200 with HTML, and the fetch path has no other reason
|
|
// to look at the bytes — the parse only happens later, in getGalleryElements.
|
|
// Persisting on status alone therefore lets an interception page overwrite a
|
|
// good copy, and the next offline start — the one case this cache exists for —
|
|
// would serve that page instead of the gallery it already had.
|
|
//
|
|
// An empty document is rejected for the same reason. It parses fine, so a
|
|
// probe that only checked the parse would let a source that answers with a
|
|
// blank body replace a populated index with one that lists nothing; from the
|
|
// user's side an empty gallery and an unparseable one are the same outage. A
|
|
// genuinely empty index is worth nothing offline anyway, so there is no case
|
|
// where keeping it beats keeping what came before.
|
|
//
|
|
// The shape check is deliberately shallow — a top-level YAML sequence — because
|
|
// this is a guard against "not an index at all", not a schema validator.
|
|
// getGalleryElements still does the real typed unmarshal.
|
|
func isUsableGalleryIndex(body []byte) bool {
|
|
var probe []any
|
|
if err := yaml.Unmarshal(body, &probe); err != nil {
|
|
return false
|
|
}
|
|
return len(probe) > 0
|
|
}
|
|
|
|
// persistGalleryIndex stores a freshly fetched index for the next time nothing
|
|
// is reachable.
|
|
//
|
|
// Every failure here is logged at debug and otherwise ignored: the copy is an
|
|
// optimisation, and a read-only or full disk must not turn a gallery that was
|
|
// fetched perfectly well into a failed listing.
|
|
func persistGalleryIndex(basePath, url string, body []byte) {
|
|
path := galleryCachePath(basePath, url)
|
|
if path == "" {
|
|
return
|
|
}
|
|
if !isUsableGalleryIndex(body) {
|
|
xlog.Debug("refusing to cache a response that is not a gallery index",
|
|
"url", url, "bytes", len(body))
|
|
return
|
|
}
|
|
// 0o750: the cache is LocalAI's own bookkeeping, so nothing outside the
|
|
// server's user and group has any reason to traverse it.
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
|
xlog.Debug("could not create gallery cache directory", "path", path, "error", err)
|
|
return
|
|
}
|
|
// Write via a temporary file so an interrupted write cannot leave a
|
|
// truncated index that the next offline start would try to parse.
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".gallery-*.tmp")
|
|
if err != nil {
|
|
xlog.Debug("could not stage gallery cache", "path", path, "error", err)
|
|
return
|
|
}
|
|
tmpName := tmp.Name()
|
|
if _, err := tmp.Write(body); err != nil {
|
|
// The write already failed; a close or unlink error on the way out
|
|
// changes nothing about the outcome and has nowhere useful to go.
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
xlog.Debug("could not write gallery cache", "path", path, "error", err)
|
|
return
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
xlog.Debug("could not flush gallery cache", "path", path, "error", err)
|
|
return
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
xlog.Debug("could not install gallery cache", "path", path, "error", err)
|
|
}
|
|
}
|
|
|
|
// fetchGalleryIndex returns the raw index bytes and the URL that served them,
|
|
// trying each candidate in order.
|
|
//
|
|
// A candidate in cooldown is skipped, unless every candidate is in cooldown —
|
|
// in which case the cooldown is ignored rather than failing outright, because
|
|
// refusing to serve a gallery we might be able to reach is worse than one slow
|
|
// request.
|
|
//
|
|
// If no candidate answers, the last known good copy on disk is served and its
|
|
// path is returned as the source. Nothing else in the chain helps a machine
|
|
// that has no network at all.
|
|
func fetchGalleryIndex(ctx context.Context, g config.Gallery, basePath string) ([]byte, string, error) {
|
|
candidates := galleryCandidates(g)
|
|
if len(candidates) == 0 {
|
|
return nil, "", fmt.Errorf("gallery %q has no URL", g.Name)
|
|
}
|
|
|
|
attempt := make([]string, 0, len(candidates))
|
|
for _, c := range candidates {
|
|
if !inCooldown(c) {
|
|
attempt = append(attempt, c)
|
|
}
|
|
}
|
|
if len(attempt) == 0 {
|
|
attempt = candidates
|
|
}
|
|
|
|
var lastErr error
|
|
for _, candidate := range attempt {
|
|
attemptCtx, cancel := context.WithTimeout(ctx, galleryFetchTimeout)
|
|
|
|
var body []byte
|
|
err := downloader.URI(candidate).ReadWithAuthorizationAndCallback(
|
|
attemptCtx, basePath, "",
|
|
func(_ string, d []byte) error {
|
|
body = d
|
|
return nil
|
|
})
|
|
cancel()
|
|
|
|
if err == nil {
|
|
// A source that answers is usable again immediately; leaving the
|
|
// record behind would keep a recovered host skipped.
|
|
galleryFailures.Delete(candidate)
|
|
// Keyed on the gallery's own URL rather than the candidate that
|
|
// answered: a mirror serves the same index, so a mirror-served
|
|
// fetch must refresh the copy an offline run will look for.
|
|
persistGalleryIndex(basePath, g.URL, body)
|
|
return body, candidate, nil
|
|
}
|
|
|
|
lastErr = err
|
|
// Only blame the source for its own failures. If the caller gave up —
|
|
// a browser disconnecting mid-listing, once a request context is wired
|
|
// through here — recording that would blackhole every candidate for ten
|
|
// minutes over something the sources had no part in.
|
|
if ctx.Err() == nil {
|
|
galleryFailures.Set(candidate, time.Now())
|
|
}
|
|
xlog.Warn("gallery source unreachable, trying the next one",
|
|
"gallery", g.Name, "url", candidate, "error", err)
|
|
}
|
|
|
|
// Every source failed. A copy from a previous run is much better than no
|
|
// gallery at all — this is what lets an offline or airgapped machine still
|
|
// list what it already knows about.
|
|
cachePath := galleryCachePath(basePath, g.URL)
|
|
if cachePath != "" {
|
|
// #nosec G304 -- cachePath is galleryCachePath's own construction: a
|
|
// hex sha256 of the URL under the fixed <basePath>/../cache/gallery
|
|
// directory, with a non-absolute basePath already rejected. No part of
|
|
// it is caller-supplied text, so there is nothing to traverse with.
|
|
if body, readErr := os.ReadFile(cachePath); readErr == nil {
|
|
xlog.Warn("all gallery sources failed, serving the last known good copy",
|
|
"gallery", g.Name, "path", cachePath, "error", lastErr)
|
|
return body, cachePath, nil
|
|
}
|
|
}
|
|
|
|
// Report what was configured and what was skipped, not just what we dialled:
|
|
// "all 1 source(s) failed" on a gallery with three mirrors reads as a
|
|
// misconfiguration and sends the operator looking for the missing mirrors,
|
|
// when the truth is that two of them are in cooldown.
|
|
return nil, "", fmt.Errorf("all %d source(s) for gallery %q failed (%d configured, %d skipped as recently failed) and no cached copy exists, last error: %w",
|
|
len(attempt), g.Name, len(candidates), len(candidates)-len(attempt), lastErr)
|
|
}
|