feat(gallery): fall back to mirrors and a cached index when the primary source fails - #11389
Merged
Conversation
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]
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]
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]
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]
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]
…ller 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]
…line 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]
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]
.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]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A LocalAI instance that cannot fetch its gallery index cannot list or install anything. Today there is exactly one URL per gallery and no recovery: if GitHub's raw endpoint is slow, rate-limited, or unreachable from the network the instance runs on, the model list is empty and stays empty.
This adds a
mirrorslist to gallery configuration, plus a last-known-good copy on disk, so a gallery has somewhere to fall back to.[ { "name": "localai", "url": "https://my-internal-mirror.corp/index.yaml", "mirrors": ["github:mudler/LocalAI/gallery/index.yaml@master"] } ]Sources are tried in order — primary, then each mirror — and a candidate that just failed is kept out of the rotation for 10 minutes so the next listing does not pay its timeout again. If every source fails, the last index that was successfully fetched and parsed is served from disk with a warning. This is useful to anyone running an internal or air-gapped mirror, and the offline tier helps even a stock install: a laptop that has listed the gallery once can list it again on a plane.
Also included: outbound HTTP requests from
pkg/downloadernow send aUser-Agent(LocalAI/<version> (<os>; <arch>)). Previously they sent none, which some CDNs and proxies treat as a bot.Defaults are unchanged
core/config/runtime_settings_startup.gois untouched —DefaultGalleriesJSONandDefaultBackendGalleriesJSONstill point atgithub:mudler/LocalAI/..., with no mirrors. This PR is mechanism only. A later, separate PR may point the defaults at an index host with GitHub as the mirror; keeping that out of here means this change can be judged on its own and reverted independently.Behaviour changes a reviewer should weigh
pkg/downloadernow treats an HTTP error status as an errorReadWithAuthorizationAndCallbacknever looked at the response status. A 404 page or a 502 from a proxy was handed to the caller as a successful read, and the caller parsed the HTML into an empty result. There are 5 non-test callers of the affected path, so this is a real behaviour change: a gallery or model-config URL that has quietly been 404ing will now surface as an error instead of an empty list that gets cached for an hour.That is the intent. Mirror fallback cannot be built on top of a read that reports failure as success — a dead primary would look fine and no mirror would ever be tried. But some users will see a misconfiguration that was previously invisible turn into a visible failure. It is isolated in its own commit (
fix(downloader): treat an HTTP error status as a failed read) if it needs to be discussed or reverted separately.A whole-transfer deadline on gallery index fetches
Each candidate attempt is now bounded at 120 seconds end to end. Before, only the response headers were bounded — a source that accepted the connection and then dribbled bytes forever would hang the fetch indefinitely, which in practice is the more common GitHub failure mode than an outright error.
120s is deliberately loose. The default index is ~2.2 MB, so it tolerates a sustained ~19 KB/s. Anything slower than that could not go on to download a model anyway, and a tighter value would push healthy-but-slow links into the 10-minute cooldown.
The offline cache
Successful fetches are written to
<MODELS_PATH>/../cache/gallery/, one file per gallery URL (name is a SHA-256 of the URL). It is never expired and is safe to delete at any time — a missing cache just means the fallback chain ends one tier earlier.Only a body that parses as a gallery index is cached. A captive portal or a proxy returning an HTML error page with HTTP 200 therefore cannot poison it.
Known limitations, called out on purpose
Mirrors do not cover a
.refgallery URL. A.refis resolved to its target before the fallback chain is entered, so if the.refitself is unreachable neither the mirrors nor the offline cache help. Documented indocs/content/features/model-gallery.mdrather than papered over.Mirror URLs are not run through
validateGalleryConfigURL. That validator guardsGetGalleryConfigFromURL, where the URL arrives in a request body. Mirrors arrive through the same operator-controlled channel as the primary URL —LOCALAI_GALLERIESor the admin-gatedPOST /api/settings— and the index fetch has never validated the primary. A mirror is no more privileged than the URL it backs up, so validating mirrors alone would buy nothing while breaking the LAN and air-gapped deployments mirrors exist for.file://mirrors stay confined to the models directory by the downloader's existingbasePathcheck. The reasoning is in a comment atgalleryCandidatesso it does not have to be rediscovered.config.Galleryis no longer comparable with==. It gained a slice.Gallery.EqualandGalleriesEqualwere added andcore/config/runtime_settings_registry.goupdated to use them. One subtlety:Equalcompares theVerificationpointer by value, where==compared pointer identity. That is a change, and it is the more correct one here — the registry diffs two independent JSON parses of the same settings, so structurally identical policies were previously reported as different. Covered by tests incore/config/gallery_test.go.A pre-existing UI bug a maintainer should know about
While updating the gallery placeholders in
Settings.jsxto mentionmirrors, it became clear the fields they annotate do nothing. The Settings page postsgalleries_jsonandbackend_galleries_json; neither key exists anywhere in the Go tree. Gallery edits made from the React UI are silently discarded while the toast reports success. This dates toaee4611aband is not fixed here — it is a separate bug with its own blast radius — but the field this PR documents is currently a no-op from the UI, and that seemed worth surfacing rather than leaving in a commit message.Also minor and pre-existing: several ginkgo suites use
os.MkdirTemp("", …)as a models path, which now makes the sibling cache directory land in/tmp/cache. This branch contains that forcore/gallerywith a packageTestMain;core/http/endpoints/localaistill exhibits it.Testing
go vetandgo test -count=1 -racepass over./internal/...,./pkg/downloader/...,./pkg/oci/...,./core/config/...,./core/gallery/.... New unit tests cover the User-Agent format, the downloader status check,Gallery.Equal, candidate ordering and deduplication, cooldown behaviour, and each fallback tier including cache write, cache read, and the parse gate on caching.Verified by hand with a deliberately dead primary (the discard port) and a working mirror: the primary fails fast with a warning, the mirror serves the listing. Re-run with every source dead, the on-disk copy serves it.
Attribution
Per
AGENTS.md, AI involvement is attributed withAssisted-by:trailers. There is noCo-Authored-Byand noSigned-off-byfrom the assistant — the human submitter adds their own sign-off if DCO is required.🤖 Generated with Claude Code