feat: send a LocalAI User-Agent on registry pulls (#10434)

LocalAI pulls models from OCI registries (via go-containerregistry), the
Ollama registry, and OCI blob stores (via oras), but every request went
out with the underlying library's generic User-Agent, so registry
operators had no way to attribute traffic to LocalAI.

Add an oci.UserAgent() helper that returns "LocalAI" (or
"LocalAI/<version>" when the binary is built with a version stamp via
internal.Version) and wire it into all three pull paths:

- pkg/oci/image.go: remote.WithUserAgent on the go-containerregistry
  image and digest requests
- pkg/oci/ollama.go: a User-Agent header on the Ollama manifest request
- pkg/oci/blob.go: a LocalAI User-Agent on the oras blob client. This
  mirrors oras' auth.DefaultClient (same retry.DefaultClient policy);
  only the advertised User-Agent changes.

Implements #6258.


Assisted-by: Claude:claude-opus-4-8 golangci-lint

Signed-off-by: Vijay Sai <vijaysaijnv@gmail.com>
This commit is contained in:
VJSai
2026-06-22 12:14:12 +05:30
committed by GitHub
parent b7d67f5779
commit 64a4351f3a
6 changed files with 70 additions and 0 deletions

View File

@@ -131,6 +131,10 @@ local-ai run ollama://gemma:2b
local-ai run oci://localai/phi-2:latest
```
{{% notice note %}}
When pulling models from Ollama or OCI registries, LocalAI identifies itself with a `LocalAI/<version>` `User-Agent` header so registry operators can attribute usage to LocalAI.
{{% /notice %}}
### Run Models via URI
To run models via URI, specify a URI to a model file or a configuration file when starting LocalAI. Valid syntax includes:

View File

@@ -11,6 +11,8 @@ import (
oras "oras.land/oras-go/v2"
"oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/retry"
)
func FetchImageBlob(ctx context.Context, r, reference, dst string, statusReader func(ocispec.Descriptor) io.Writer) error {
@@ -28,6 +30,16 @@ func FetchImageBlob(ctx context.Context, r, reference, dst string, statusReader
}
repo.SkipReferrersGC = true
// Identify LocalAI to the registry. This mirrors oras' auth.DefaultClient
// (same retry policy) but advertises a LocalAI User-Agent instead of the
// library default.
client := &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
}
client.SetUserAgent(UserAgent())
repo.Client = client
// https://github.com/oras-project/oras/blob/main/cmd/oras/internal/option/remote.go#L364
// https://github.com/oras-project/oras/blob/main/cmd/oras/root/blob/fetch.go#L136
desc, reader, err := oras.Fetch(ctx, repo.Blobs(), reference, oras.DefaultFetchOptions)

View File

@@ -176,6 +176,7 @@ func GetImage(targetImage, targetPlatform string, auth *registrytypes.AuthConfig
opts := []remote.Option{
remote.WithTransport(tr),
remote.WithPlatform(*platform),
remote.WithUserAgent(UserAgent()),
}
if auth != nil {
opts = append(opts, remote.WithAuth(staticAuth{auth}))
@@ -223,6 +224,7 @@ func GetImageDigest(targetImage, targetPlatform string, auth *registrytypes.Auth
opts := []remote.Option{
remote.WithTransport(tr),
remote.WithPlatform(*platform),
remote.WithUserAgent(UserAgent()),
}
if auth != nil {
opts = append(opts, remote.WithAuth(staticAuth{auth}))

View File

@@ -47,6 +47,7 @@ func OllamaModelManifest(image string) (*Manifest, error) {
return nil, err
}
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
req.Header.Set("User-Agent", UserAgent())
client := httpclient.New(httpclient.WithFollowRedirects())
resp, err := client.Do(req)
if err != nil {

19
pkg/oci/useragent.go Normal file
View File

@@ -0,0 +1,19 @@
package oci
import (
"fmt"
"github.com/mudler/LocalAI/internal"
)
// UserAgent returns the User-Agent string LocalAI sends on outbound registry
// requests (OCI registries and Ollama). It identifies the client as LocalAI
// and, when the binary was built with a version stamp, appends it so registries
// can attribute client-side usage to LocalAI rather than to the generic
// User-Agent of the underlying transport library.
func UserAgent() string {
if internal.Version == "" {
return "LocalAI"
}
return fmt.Sprintf("LocalAI/%s", internal.Version)
}

32
pkg/oci/useragent_test.go Normal file
View File

@@ -0,0 +1,32 @@
package oci_test
import (
"github.com/mudler/LocalAI/internal"
. "github.com/mudler/LocalAI/pkg/oci"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("OCI", func() {
Context("UserAgent", func() {
var savedVersion string
BeforeEach(func() {
savedVersion = internal.Version
})
AfterEach(func() {
internal.Version = savedVersion
})
It("identifies as LocalAI when no version is stamped", func() {
internal.Version = ""
Expect(UserAgent()).To(Equal("LocalAI"))
})
It("appends the build version when one is stamped", func() {
internal.Version = "v3.2.1"
Expect(UserAgent()).To(Equal("LocalAI/v3.2.1"))
})
})
})