Skip to content

feat(gallery): fall back to mirrors and a cached index when the primary source fails - #11389

Merged
mudler merged 9 commits into
masterfrom
feat/gallery-mirrors
Aug 6, 2026
Merged

feat(gallery): fall back to mirrors and a cached index when the primary source fails#11389
mudler merged 9 commits into
masterfrom
feat/gallery-mirrors

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

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 mirrors list 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/downloader now send a User-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.go is untouched — DefaultGalleriesJSON and DefaultBackendGalleriesJSON still point at github: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/downloader now treats an HTTP error status as an error

ReadWithAuthorizationAndCallback never 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 .ref gallery URL. A .ref is resolved to its target before the fallback chain is entered, so if the .ref itself is unreachable neither the mirrors nor the offline cache help. Documented in docs/content/features/model-gallery.md rather than papered over.

Mirror URLs are not run through validateGalleryConfigURL. That validator guards GetGalleryConfigFromURL, where the URL arrives in a request body. Mirrors arrive through the same operator-controlled channel as the primary URL — LOCALAI_GALLERIES or the admin-gated POST /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 existing basePath check. The reasoning is in a comment at galleryCandidates so it does not have to be rediscovered.

config.Gallery is no longer comparable with ==. It gained a slice. Gallery.Equal and GalleriesEqual were added and core/config/runtime_settings_registry.go updated to use them. One subtlety: Equal compares the Verification pointer 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 in core/config/gallery_test.go.

A pre-existing UI bug a maintainer should know about

While updating the gallery placeholders in Settings.jsx to mention mirrors, it became clear the fields they annotate do nothing. The Settings page posts galleries_json and backend_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 to aee4611ab and 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 for core/gallery with a package TestMain; core/http/endpoints/localai still exhibits it.

Testing

go vet and go test -count=1 -race pass 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 with Assisted-by: trailers. There is no Co-Authored-By and no Signed-off-by from the assistant — the human submitter adds their own sign-off if DCO is required.

🤖 Generated with Claude Code

mudler added 8 commits August 6, 2026 07:11
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]
Comment thread core/gallery/gallery_mirrors.go Fixed
Comment thread core/gallery/gallery_mirrors.go Fixed
Comment thread core/gallery/gallery_mirrors.go Fixed
Comment thread core/gallery/gallery_mirrors.go Fixed
Comment thread core/gallery/gallery_mirrors.go Fixed
Comment thread core/gallery/gallery_mirrors.go Fixed
.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]
@mudler
mudler merged commit a77780a into master Aug 6, 2026
69 of 70 checks passed
@mudler
mudler deleted the feat/gallery-mirrors branch August 6, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants