Files
LocalAI/core/application/startup.go
T
mudler-agentandEttore Di Giacinto c7ce0c602c fix: stop listing the models directory on request paths and cache GGUF header reads (#12683)
* fix(http): stop listing the models directory on embeddings and bearer requests

Every embeddings request read the whole models directory before the body
was parsed, even when the body named its model. On a network-backed
models directory this cost seconds per request.

Embeddings now binds the body first and looks up a default model only
when the body has none, like chat. The pre-bind default lookup and the
gpt-4o constant default are gone from that route.

The first-available default lookup skips the directory when the filter
cannot accept an unconfigured file. Every usecase filter rejects files
without a config, so the scan never added results for them. Match-all,
nil and name filters keep the scan.

The bearer token check is now an exact test: a configured name, or one
stat of that name in the models directory. The value is a literal, not a
regular expression, and a name that is not a local relative path never
matches a file.

Assisted-by: Claude Code:claude-haiku-5-5

* perf(config): cache GGUF header reads for model defaults

Model defaults re-parsed the GGUF header on every SetDefaults call, which
runs per request and at startup reload. Put a bounded LRU in front of the
parser, keyed by absolute path and validated by size and modification
time. The cached value is a snapshot of the fields the defaults use, not
the parser output.

A changed size or mtime is a miss. Missing files and parse errors are not
cached, so the existing fallback is unchanged. LOCALAI_DISABLE_GUESSING,
the empty-path guard, the alias early return, the panic recovery and the
context-size default are all kept in front of the cache.

Assisted-by: Claude:claude-haiku-5-5

* test(http): cover listing-free default lookup on every usecase route

Assisted-by: Claude Code:claude-haiku-5-5

* feat(config): persist the GGUF header cache per instance

The header cache kept parsed GGUF results only in memory, so every restart
parsed each model file again before its defaults were known. Write the cache
to a per-instance file in the data path and load it at startup. A restarted
frontend with a fixed instance ID starts warm.

The file is versioned and records the estimator inputs (gguf-parser version,
candidate contexts, auto context size, offload count). A mismatch on any of
them makes the file ignored. Each entry keeps path, size and mtime, so a
changed model file is a miss on load. Writes run on one background saver,
debounced, through a temp file and rename. The entry bound applies to the
file as well. Without a data path the cache stays in memory only.

Documented as a per-instance exception in the model configuration docs.

Assisted-by: Claude:claude-haiku-5-5

* docs(embeddings): document model selection and the no-model error

Assisted-by: Claude Code:claude-haiku-5-5

* refactor(config): split the header cache into a generic cache

The GGUF header cache mixed two things: the mechanics (bounded LRU keyed
by path and identity, per-instance file, debounced atomic save, tolerant
load) and the GGUF fields. Move the mechanics into headerCache[S], driven
by a headerCacheSpec[S] that names the file prefix, schema, fingerprint,
parser and the snapshot encode/decode pair. The GGUF cache becomes one
instance of it.

File names, the identity check, LRU size, debounce, atomic write and the
corruption handling are unchanged. The on-disk JSON keeps the "estimator"
key so existing files still load.

Assisted-by: Claude Code:claude-haiku-5-5

* feat(config): share one GGUF header cache file between frontends

Every frontend used to write its own header cache file, named after its
instance ID, so a header read by one frontend was never reused by another.
The cache now uses one file, header-cache.json, in the data path, for all
frontends and for standalone mode. The instance ID no longer changes the
file name.

Frontends share the file in three ways. A save first merges the file's
entries in, so one frontend's write does not drop another's entries. A
miss reloads the file when its modification time has changed, and a hit
never reads it. Writes go to a temp file that is renamed into place, so a
reader never sees a partial file.

Two frontends saving at the same moment can still drop each other's last
entries. That costs one re-parse, never a wrong value, because every
entry is checked against its model file before use.

The shared file is valid only when every frontend mounts the same data
volume. The distributed-mode and model-configuration docs state this mode
and its volume limit.

Assisted-by: Claude:claude-haiku-5-5

* feat(config): give an instance ID its own header cache file

Assisted-by: Claude Code:claude-haiku-5-5

* style(config): gofmt the distributed config struct

Assisted-by: Claude Code:claude-haiku-5-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-14 01:06:14 +02:00

783 lines
34 KiB
Go

package application
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"time"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/failover"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/jobs"
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/modeladmin"
"github.com/mudler/LocalAI/core/services/monitoring"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/core/services/routing/admission"
"github.com/mudler/LocalAI/core/services/routing/billing"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/services/routing/router"
"github.com/mudler/LocalAI/core/services/storage"
coreStartup "github.com/mudler/LocalAI/core/startup"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/internal"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/modelartifacts"
"github.com/mudler/LocalAI/pkg/signals"
"github.com/mudler/LocalAI/pkg/vram"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/sanitize"
"github.com/mudler/LocalAI/pkg/xsysinfo"
"github.com/mudler/xlog"
)
func New(opts ...config.AppOption) (*Application, error) {
options := config.NewApplicationConfig(opts...)
// Store a copy of the startup config (env/CLI only, before file
// loading): the settings endpoint uses it to tell env-provided API
// keys apart from runtime-managed ones.
startupConfigCopy := *options
// Merge persisted runtime settings BEFORE anything consumes options:
// model-config defaults (ToConfigLoaderOptions), gallery services, the
// watchdog, and the MITM listener are all configured from options
// further down. Loading late (the old call site, after model configs
// were read) meant boot-loaded models saw pre-file defaults for one
// full restart. Env/CLI values win over the file (see
// ApplyRuntimeSettingsAtStartup).
loadRuntimeSettingsFromFile(options)
// WithThreads no longer eagerly resolves 0 (so the settings merge can
// tell "unset" from "env-set"); resolve the physical-core default now
// that env, CLI, and file have all had their say.
if options.Threads == 0 {
options.Threads = xsysinfo.CPUPhysicalCores()
}
trace.ConfigureBackendTracePersistence(options.DataPath)
application := newApplication(options)
application.startupConfig = &startupConfigCopy
xlog.Info("Starting LocalAI", "threads", options.Threads, "modelsPath", options.SystemState.Model.ModelsPath)
xlog.Info("LocalAI version", "version", internal.PrintableVersion())
if err := application.start(); err != nil {
return nil, err
}
caps, err := xsysinfo.CPUCapabilities()
if err == nil {
xlog.Debug("CPU capabilities", "capabilities", caps)
}
gpus, err := xsysinfo.GPUs()
if err == nil {
xlog.Debug("GPU count", "count", len(gpus))
for _, gpu := range gpus {
xlog.Debug("GPU", "gpu", gpu.String())
}
}
// Make sure directories exists
if options.SystemState.Model.ModelsPath == "" {
return nil, fmt.Errorf("models path cannot be empty")
}
err = os.MkdirAll(options.SystemState.Model.ModelsPath, 0o750)
if err != nil {
return nil, fmt.Errorf("unable to create ModelPath: %q", err)
}
// Reap *.partial downloads abandoned by a previous run (killed mid-transfer
// by an OOM/restart, or stalled before cleanup could run). The 24h window
// is well beyond any legitimate in-flight download, so this never trims an
// active transfer; it just stops dead partials accumulating on the volume.
if removed, cErr := downloader.CleanupStalePartialFiles(options.SystemState.Model.ModelsPath, 24*time.Hour); cErr != nil {
xlog.Warn("Failed to reap stale partial downloads", "error", cErr)
} else if removed > 0 {
xlog.Info("Reaped stale partial downloads", "count", removed)
}
// Managed artifacts stage into a per-writer tree, which a crashed writer's
// successor no longer overwrites for it, so the tree itself needs reaping
// too. Sweeping here as well as on the materialization path is what
// reclaims a volume whose abandoned artifact is never requested again.
if removed, cErr := modelartifacts.SweepStalePartialTrees(options.SystemState.Model.ModelsPath, modelartifacts.PartialOrphanTTL, ""); cErr != nil {
xlog.Warn("Failed to reap abandoned artifact partials", "error", cErr)
} else if removed > 0 {
xlog.Info("Reaped abandoned artifact partials", "count", removed)
}
if options.GeneratedContentDir != "" {
err := os.MkdirAll(options.GeneratedContentDir, 0o750)
if err != nil {
return nil, fmt.Errorf("unable to create ImageDir: %q", err)
}
}
if options.UploadDir != "" {
err := os.MkdirAll(options.UploadDir, 0o750)
if err != nil {
return nil, fmt.Errorf("unable to create UploadDir: %q", err)
}
}
// Create and migrate data directory
if options.DataPath != "" {
if err := os.MkdirAll(options.DataPath, 0o750); err != nil {
return nil, fmt.Errorf("unable to create DataPath: %q", err)
}
// Migrate data from DynamicConfigsDir to DataPath if needed
if options.DynamicConfigsDir != "" && options.DataPath != options.DynamicConfigsDir {
migrateDataFiles(options.DynamicConfigsDir, options.DataPath)
}
}
// Initialize auth database if auth is enabled
if options.Auth.Enabled {
// Auto-generate HMAC secret if not provided
if options.Auth.APIKeyHMACSecret == "" {
secretFile := filepath.Join(options.DataPath, ".hmac_secret")
secret, err := loadOrGenerateHMACSecret(secretFile)
if err != nil {
return nil, fmt.Errorf("failed to initialize HMAC secret: %w", err)
}
options.Auth.APIKeyHMACSecret = secret
}
authDB, err := auth.InitDB(options.Auth.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("failed to initialize auth database: %w", err)
}
application.authDB = authDB
xlog.Info("Auth enabled", "database", sanitize.URL(options.Auth.DatabaseURL))
// Start session and expired API key cleanup goroutine
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-options.Context.Done():
return
case <-ticker.C:
if err := auth.CleanExpiredSessions(authDB); err != nil {
xlog.Error("failed to clean expired sessions", "error", err)
}
if err := auth.CleanExpiredAPIKeys(authDB); err != nil {
xlog.Error("failed to clean expired API keys", "error", err)
}
}
}
}()
}
// Initialize the OTel + Prometheus metric pipeline before any
// counter is created. monitoring.NewLocalAIMetricsService calls
// otel.SetMeterProvider, so any subsequent otel.Meter() call —
// including billing.NewRecorder below — sees the real provider
// rather than the no-op global. Initialising metrics later (in
// core/http/app.go) leaves billing's counters bound to a no-op
// meter and never reaches /metrics. We deliberately ignore
// DisableMetrics here for ordering purposes; the HTTP middleware
// that records api_call histograms is still gated.
if !options.DisableMetrics {
ms, err := monitoring.NewLocalAIMetricsService()
if err != nil {
xlog.Error("failed to initialize metrics provider", "error", err)
} else {
application.metricsService = ms
// Bind the billing package's counters to the same meter the
// metrics service exports. Without this, billing's counters
// resolve via the OTel global and never reach /metrics.
billing.SetMeter(ms.Meter)
}
}
// Wire the routing-module billing recorder. The recorder runs in
// every mode (auth on/off, distributed/single-node) so that token
// tracking is not gated on auth — a no-auth single-user box still
// gets dashboards and `/api/usage` populated.
//
// fallbackUser is wired *unconditionally* when stats are enabled.
// UsageMiddleware uses it as the attribution source whenever
// auth.GetUser(c) is nil — that covers (a) no-auth deployments and
// (b) internal callers under auth-on (cron flushers, distributed
// worker callbacks) that hit a recordable endpoint without a user
// in context. The billing.user_id_present invariant still rejects
// empty IDs; LocalUser() returns a stable UUID per data path.
if !options.DisableStats {
var statsBackend billing.StatsBackend
switch {
case application.authDB != nil:
statsBackend = billing.NewGormBackend(application.authDB, 0, 0)
xlog.Info("stats: using auth DB for usage records")
default:
statsBackend = billing.NewMemoryBackend(0)
xlog.Info("stats: using in-memory ring buffer (no-auth single-user mode)")
}
application.fallbackUser = billing.LocalUser(options.DataPath)
application.statsRecorder = billing.NewRecorder(statsBackend)
// Drain pending records on SIGTERM. The GORM backend buffers up
// to maxPending (5k) records across a 5s flush tick, so without
// this the last few seconds of usage disappear on graceful exit.
signals.RegisterGracefulTerminationHandler(func() {
_ = application.statsRecorder.Close()
})
xlog.Info("stats: fallback user wired", "local_user_id", application.fallbackUser.ID)
} else {
xlog.Info("stats: disabled by --disable-stats")
}
// Wire the PII filter subsystem. The redactor is now a stateless
// handle — detection is driven by per-model NER detectors
// (pii.detectors → the detector model's pii_detection policy), run
// request-side by the chat middleware and the MITM input path. The
// regex tier was removed; redaction is opt-in per model via
// PIIIsEnabled(). The event store backs the /api/pii/events audit log.
application.piiRedactor = &pii.Redactor{}
application.piiEvents = pii.NewMemoryEventStore(0)
// Wire the routing decision log. Always-on when stats are enabled —
// the per-router admin page reads this as the live activity feed
// and as input to drift checks for subsystem 5. Embedders may retain this
// bounded log independently without enabling billing stats.
if !options.DisableStats || options.RouterDecisionLog {
application.routerDecisions = router.NewMemoryDecisionStore(0)
}
// Process-wide classifier cache shared across all route middlewares so
// the embedding-cache stats endpoint sees a single source of truth.
application.routerRegistry = router.NewRegistry()
// Failover chains: probe targets and track which one is active per
// chain. WithOnWarmChanged pins and preloads warm local targets so a
// switch to them does not wait for a cold load.
application.failoverManager = failover.New(application.ModelConfigLoader(),
failover.WithProber(failover.NewProber(failoverLoadedBackend(application.ModelLoader()), options.ProxyAPIKeyEnvLookup)),
failover.WithOnWarmChanged(application.applyFailoverWarmTargets),
)
// The assistant client was built in start() (above), before this
// manager existed; wire it now so list_failover_chains /
// pin_failover_target / unpin_failover_target see real chains.
if application.assistantClient != nil {
application.assistantClient.Failover = application.failoverManager
}
// Subsystem 5: admission control. Limiter is always wired so a
// model that gains a limits: block via gallery install or YAML
// edit takes effect on the next restart without conditional plumbing.
application.admissionLimiter = admission.New()
// Wire JobStore for DB-backed task/job persistence whenever auth DB is available.
// This ensures tasks and jobs survive restarts in both single-node and distributed modes.
if application.authDB != nil && application.agentJobService != nil {
dbJobStore, err := jobs.NewJobStore(application.authDB)
if err != nil {
xlog.Error("Failed to create job store for auth DB", "error", err)
} else {
application.agentJobService.SetDistributedJobStore(dbJobStore)
}
}
// Initialize distributed mode services (NATS, object storage, node registry)
// revisionStore is built inside the distributed block below but used after
// the model configs are loaded, so it is declared out here.
var revisionStore modeladmin.RevisionStore
// modelConfigResync is built there too, and started only once the model
// configs are loaded: a pass against an empty loader would treat every
// model as new.
var modelConfigResync *modeladmin.DirectoryResync
distSvc, err := initDistributed(options, application.authDB, application.ModelConfigLoader(),
&failoverPinnedResolver{base: application.ModelConfigLoader(), fm: application.failoverManager})
if err != nil {
return nil, fmt.Errorf("distributed mode initialization failed: %w", err)
}
if distSvc != nil {
application.distributed = distSvc
// Before failoverManager.Run starts below: the gate and sync must be
// in place for its first tick.
application.startFailoverDistributed(options.Context)
// Wire remote model unloader so ShutdownModel works for remote nodes
// Uses NATS to tell serve-backend nodes to Free + kill their backend process
application.modelLoader.SetRemoteUnloader(distSvc.Unloader)
// Wire ModelRouter so grpcModel() delegates to SmartRouter in distributed mode
application.modelLoader.SetModelRouter(distSvc.ModelAdapter.AsModelRouter())
// Wire DistributedModelStore so shutdown/list/watchdog can find remote models
distStore := nodes.NewDistributedModelStore(
model.NewInMemoryModelStore(),
distSvc.Registry,
)
application.modelLoader.SetModelStore(distStore)
// Drop the local stub when a model's last replica leaves the registry.
// The store reports local stubs UNION registry rows, and every removal
// path deletes the row only, so without this the frontend keeps
// reporting a model as loaded long after the replica is gone.
// Registered unconditionally: this is independent of the prefix cache.
distSvc.Registry.AddReplicaRemovedHook(
nodes.NewLocalStubInvalidator(distSvc.Registry, distStore),
)
// Start health monitor
distSvc.Health.Start(options.Context)
// Start replica reconciler for auto-scaling model replicas
if distSvc.Reconciler != nil {
go distSvc.Reconciler.Run(options.Context)
}
go distSvc.ModelCleanup.Run(options.Context)
// In distributed mode, MCP CI jobs are executed by agent workers (not the frontend)
// because the frontend can't create MCP sessions (e.g., stdio servers using docker).
// The dispatcher only enqueues jobs and persists the results and traces workers publish.
// Wire model config loader so job events include model config for agent workers
distSvc.Dispatcher.SetModelConfigLoader(application.backendLoader)
// Start job dispatcher — abort startup if it fails, as jobs would be accepted but never dispatched
if err := distSvc.Dispatcher.Start(options.Context); err != nil {
return nil, fmt.Errorf("starting job dispatcher: %w", err)
}
// Start ephemeral file cleanup
storage.StartEphemeralCleanup(options.Context, distSvc.FileMgr, 0, 0)
// Wire distributed backends into AgentJobService (before Start)
if application.agentJobService != nil {
application.agentJobService.SetDistributedBackends(distSvc.Dispatcher)
application.agentJobService.SetDistributedJobStore(distSvc.JobStore)
// Keep agent tasks consistent across replicas (jobs already sync via the
// dispatcher + DB read-through). Same NATS client the dispatcher uses.
application.agentJobService.SetTaskSyncNATS(distSvc.Nats)
}
// Wire skill store into AgentPoolService (wired at pool start time via closure)
// The actual wiring happens in StartAgentPool since the pool doesn't exist yet.
// Wire NATS and gallery store into GalleryService for cross-instance progress/cancel
if application.galleryService != nil {
application.galleryService.SetNATSClient(distSvc.Nats)
if distSvc.DistStores != nil && distSvc.DistStores.Gallery != nil {
// Clean up stale in-progress operations from previous crashed instances
if _, err := distSvc.DistStores.Gallery.CleanStale(30 * time.Minute); err != nil {
xlog.Warn("Failed to clean stale gallery operations", "error", err)
}
application.galleryService.SetGalleryStore(distSvc.DistStores.Gallery)
// Reap stale ops periodically, not just at boot: an op orphaned by
// a replica that died mid-install (its foreground handler goroutine
// gone) would otherwise linger "processing" in the UI until the next
// restart. 30m matches the install/upgrade ceiling so a genuinely
// slow op is never reaped out from under itself.
gsvc := application.galleryService
go func() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-options.Context.Done():
return
case <-ticker.C:
if _, err := gsvc.ReapStaleOperations(30 * time.Minute); err != nil {
xlog.Warn("Failed to reap stale gallery operations", "error", err)
}
}
}
}()
}
// Hydrate from the store first so the wildcard subscriber finds an
// already-populated statuses map for any operations still in flight
// on a peer replica.
if err := application.galleryService.Hydrate(); err != nil {
xlog.Warn("Gallery service hydrate failed", "error", err)
}
// Bind cache-invalidation handler before SubscribeBroadcasts so the
// first inbound event is already routed. Peer replicas install a
// model and broadcast on SubjectCacheInvalidateModels; this
// callback re-runs LoadModelConfigsFromPath so a subsequent chat
// completion that load-balances onto this replica finds the new
// config. The originating replica reloads inline in modelHandler
// and never enters this path.
gs := application.galleryService
sys := options.SystemState
cfgLoaderOpts := options.ToConfigLoaderOptions()
modelRevisionLifecycle := modeladmin.NewDistributedModelRevisionLifecycle(distSvc.Registry, distSvc.ModelCleanup)
gs.SetModelRevisionLifecycle(modelRevisionLifecycle)
// Captured here, used after the model configs are loaded below: the
// resync reads the loader, which is still empty at this point.
revisionStore = modeladmin.NewRevisionStore(distSvc.Registry, modelRevisionLifecycle)
modelConfigResync = modeladmin.NewDirectoryResync(application.ModelConfigLoader(), sys.Model.ModelsPath, modelRevisionLifecycle, cfgLoaderOpts...)
gs.OnModelsChanged = func(evt messaging.CacheInvalidateEvent) {
// ApplyRemoteChange honors the op: a "delete" prunes the element
// (a reload-from-path is additive and cannot drop it), anything
// else reloads from disk; a named element's running instance is
// shut down so the new config takes effect. The originating
// replica reloads inline and never depends on this path.
if err := modeladmin.ApplyRemoteChange(options.Context, application.ModelConfigLoader(), sys.Model.ModelsPath, evt, modelRevisionLifecycle, cfgLoaderOpts...); err != nil {
xlog.Warn("Failed to apply peer model config change", "error", err)
}
}
if err := application.galleryService.SubscribeBroadcasts(); err != nil {
xlog.Warn("Gallery service subscribe failed", "error", err)
}
// Wire distributed model/backend managers so delete propagates to workers
application.galleryService.SetModelManager(
nodes.NewDistributedModelManager(options, application.modelLoader, distSvc.Unloader),
)
application.galleryService.SetBackendManager(
nodes.NewDistributedBackendManager(options, application.modelLoader, distSvc.Unloader, distSvc.Registry, application.galleryService),
)
}
}
// Start AgentJobService (after distributed wiring so it knows whether to use local or NATS)
if application.agentJobService != nil {
if err := application.agentJobService.Start(options.Context); err != nil {
return nil, fmt.Errorf("starting agent job service: %w", err)
}
}
if err := coreStartup.InstallModels(options.Context, application.GalleryService(), options.Galleries, options.BackendGalleries, options.SystemState, application.ModelLoader(), options.EnforcePredownloadScans, options.AutoloadBackendGalleries, options.RequireBackendIntegrity, nil, options.ModelsURL...); err != nil {
xlog.Error("error installing models", "error", err)
}
for _, backend := range options.ExternalBackends {
if err := galleryop.InstallExternalBackend(options.Context, options.BackendGalleries, options.SystemState, application.ModelLoader(), nil, backend, "", "", false, options.RequireBackendIntegrity); err != nil {
xlog.Error("error installing external backend", "error", err)
}
}
// Attach the GGUF header cache now, before any model config is loaded, so the
// first load already starts from the shared file.
// Only an ID the operator set keys a per-instance file; a generated ID would
// give every restart its own file.
cacheInstanceID := ""
if !options.Distributed.InstanceIDGenerated {
cacheInstanceID = options.Distributed.InstanceID
}
config.ConfigureGGUFHeaderCache(options.Context, options.DataPath, cacheInstanceID)
configLoaderOpts := options.ToConfigLoaderOptions()
if err := application.ModelConfigLoader().LoadModelConfigsFromPath(options.SystemState.Model.ModelsPath, configLoaderOpts...); err != nil {
xlog.Error("error loading config files", "error", err)
}
// Bring the controller's stored revisions back in line with the
// configuration just loaded. An inference request may only establish a
// revision, never replace one, so a model whose stored value has drifted
// stays unroutable until something republishes it. This has to run after
// the load above: the loader is empty until then, and a resync against an
// empty loader silently reconciles nothing.
if revisionStore != nil {
if err := modeladmin.ResyncModelConfigRevisions(options.Context, application.ModelConfigLoader(), options, revisionStore); err != nil {
xlog.Warn("Failed to resync model config revisions", "error", err)
}
}
if err := gallery.RegisterBackends(options.SystemState, application.ModelLoader()); err != nil {
xlog.Error("error registering external backends", "error", err)
}
// Start background upgrade checker for backends.
// In distributed mode, uses PostgreSQL advisory lock so only one frontend
// instance runs periodic checks (avoids duplicate upgrades across replicas).
if len(options.BackendGalleries) > 0 {
// Pass a lazy getter for the backend manager so the checker always
// uses the active one — DistributedBackendManager is swapped in above
// and asks workers for their installed backends, which is what
// upgrade detection needs in distributed mode.
bmFn := func() galleryop.BackendManager { return application.GalleryService().BackendManager() }
uc := NewUpgradeChecker(options, application.ModelLoader(), application.distributedDB(), bmFn)
application.upgradeChecker = uc
// Refresh the upgrade cache the moment a backend op finishes — otherwise
// the UI keeps showing a just-upgraded backend as upgradeable until the
// next 6-hour tick. TriggerCheck is non-blocking.
if gs := application.GalleryService(); gs != nil {
gs.OnBackendOpCompleted = uc.TriggerCheck
}
go uc.Run(options.Context)
}
// Wire gallery generation counter into VRAM caches so they invalidate
// when gallery data refreshes instead of using a fixed TTL.
vram.SetGalleryGenerationFunc(gallery.GalleryGeneration)
if options.AutoloadGalleries {
if options.VRAMPersistentCache {
// Remote GGUF probes can transfer substantial metadata. Keep successful
// results across restarts so the startup warmer does not repeat that work.
vram.ConfigurePersistentCache(filepath.Join(options.SystemState.Model.ModelsPath, "..", "cache", "vram"), 24*time.Hour)
}
// Fill those caches ahead of the first visitor. An estimate for an entry
// nobody has asked about yet costs a remote probe of its weight files, and
// the model gallery asks for one per row, so without this the first page
// spends seconds filling in its own sizes while somebody watches it.
// Non-blocking, and bounded: see DefaultEstimateWarmConfig.
gallery.WarmEstimateCache(options.Context, options.Galleries, options.SystemState, gallery.EstimateWarmConfigFromEnv())
}
if options.ConfigFile != "" {
if err := application.ModelConfigLoader().LoadMultipleModelConfigsSingleFile(options.ConfigFile, configLoaderOpts...); err != nil {
xlog.Error("error loading config file", "error", err)
}
}
if err := application.ModelConfigLoader().PreloadWithContext(options.Context, options.SystemState.Model.ModelsPath); err != nil {
xlog.Error("error downloading models", "error", err)
}
// Catch up on model config changes whose invalidation this frontend
// missed. NATS keeps no history, so a change published while this
// frontend was disconnected is otherwise never applied here. Every
// frontend runs its own pass against the shared models directory; the
// pass is idempotent, so no leader is needed.
if modelConfigResync != nil && distSvc != nil {
modelConfigResync.Start(options.Context, options.Distributed.ModelConfigResyncIntervalOrDefault(), distSvc.Nats)
}
if options.PreloadJSONModels != "" {
if err := galleryop.ApplyGalleryFromString(options.SystemState, application.ModelLoader(), options.EnforcePredownloadScans, options.AutoloadBackendGalleries, options.Galleries, options.BackendGalleries, options.PreloadJSONModels, options.RequireBackendIntegrity, gallery.WithArtifactMaterializer(options.ModelArtifactMaterializer)); err != nil {
return nil, err
}
}
if options.PreloadModelsFromPath != "" {
if err := galleryop.ApplyGalleryFromFile(options.SystemState, application.ModelLoader(), options.EnforcePredownloadScans, options.AutoloadBackendGalleries, options.Galleries, options.BackendGalleries, options.PreloadModelsFromPath, options.RequireBackendIntegrity, gallery.WithArtifactMaterializer(options.ModelArtifactMaterializer)); err != nil {
return nil, err
}
}
if options.Debug {
for _, v := range application.ModelConfigLoader().GetAllModelsConfigs() {
xlog.Debug("Model", "name", v.Name, "config", v)
}
}
// Wire the cloudproxy MITM listener. Opt-in: empty MITMListen
// means "no MITM" — operators must explicitly choose to start
// it because clients have to install the generated CA cert.
// The handler reuses the global redactor + event store so an
// admin who's already configured PII filtering for direct API
// traffic doesn't need a parallel config for MITM traffic.
// Runs after loadRuntimeSettingsFromFile so a listener configured
// via /api/settings is brought back up across restarts.
startMITMIfConfigured(application, options)
application.ModelLoader().SetBackendLoggingEnabled(options.EnableBackendLogging)
// Safety-net cleanup if the application context is cancelled without
// the caller invoking Shutdown directly. This is fire-and-forget — it
// races binary exit and is unreliable in tests; the deterministic path
// is application.Shutdown(), which Shutdown's sync.Once dedupes with
// this goroutine.
go func() {
<-options.Context.Done()
xlog.Debug("Context canceled, shutting down")
if err := application.Shutdown(); err != nil {
xlog.Error("error while stopping all grpc backends", "error", err)
}
}()
// Initialize watchdog with current settings (after loading from file)
initializeWatchdog(application, options)
if options.LoadToMemory != nil && !options.SingleBackend {
for _, m := range options.LoadToMemory {
xlog.Debug("Auto loading model into memory from file", "model", m)
// Same path as POST /backend/load: a realtime pipeline model expands
// to its sub-models, and load failures are recorded as model_load
// traces.
if _, err := backend.PreloadModelByName(options.Context, application.ModelConfigLoader(), application.ModelLoader(), options, m); err != nil {
return nil, err
}
}
}
// Start the failover scheduler: it syncs chains from config, runs
// liveness/recovery probes and dwell-based fail-back. Run is the only
// caller of Sync in production so onWarm callbacks stay ordered.
application.registerFailoverMetrics()
go application.failoverManager.Run(options.Context)
// Watch the configuration directory
startWatcher(options)
// Everything that must happen before this process can serve a request has
// happened. Flip readiness last, and only on the success path — the early
// `return nil, err` exits above abort startup, and an application that
// never finished starting must never report itself ready.
application.markStartupComplete()
xlog.Info("core/startup process completed!")
return application, nil
}
func startWatcher(options *config.ApplicationConfig) {
if options.DynamicConfigsDir == "" {
// No need to start the watcher if the directory is not set
return
}
if _, err := os.Stat(options.DynamicConfigsDir); err != nil {
if os.IsNotExist(err) {
// We try to create the directory if it does not exist and was specified
if err := os.MkdirAll(options.DynamicConfigsDir, 0o700); err != nil {
xlog.Error("failed creating DynamicConfigsDir", "error", err)
}
} else {
// something else happened, we log the error and don't start the watcher
xlog.Error("failed to read DynamicConfigsDir, watcher will not be started", "error", err)
return
}
}
configHandler := newConfigFileHandler(options)
if err := configHandler.Watch(); err != nil {
xlog.Error("failed creating watcher", "error", err)
}
}
// loadRuntimeSettingsFromFile merges runtime_settings.json into options
// with env-over-file precedence. Field coverage and the precedence rules
// live in the config registry (ApplyRuntimeSettingsAtStartup); this wrapper
// only handles the file read. No-op when DynamicConfigsDir is unset.
func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
if options.DynamicConfigsDir == "" {
return
}
// ReadPersistedSettings treats a missing file as zero settings, so probe
// for it here only to keep the log honest: "loaded" must mean a file was
// actually read, not that we merged an all-nil struct.
if _, err := os.Stat(filepath.Join(options.DynamicConfigsDir, "runtime_settings.json")); os.IsNotExist(err) {
xlog.Debug("runtime_settings.json not found, using defaults")
return
}
settings, err := options.ReadPersistedSettings()
if err != nil {
xlog.Warn("failed to read runtime_settings.json", "error", err)
return
}
options.ApplyRuntimeSettingsAtStartup(&settings)
xlog.Debug("Runtime settings loaded from runtime_settings.json")
}
// initializeWatchdog initializes the watchdog with current ApplicationConfig settings
func initializeWatchdog(application *Application, options *config.ApplicationConfig) {
// Get effective max active backends (considers both MaxActiveBackends and deprecated SingleBackend)
lruLimit := options.GetEffectiveMaxActiveBackends()
// Create watchdog if enabled OR if LRU limit is set OR if memory reclaimer is enabled
if options.WatchDog || lruLimit > 0 || options.MemoryReclaimerEnabled {
wd := model.NewWatchDog(
model.WithProcessManager(application.ModelLoader()),
model.WithBusyTimeout(options.WatchDogBusyTimeout),
model.WithIdleTimeout(options.WatchDogIdleTimeout),
model.WithWatchdogInterval(options.WatchDogInterval),
model.WithBusyCheck(options.WatchDogBusy),
model.WithIdleCheck(options.WatchDogIdle),
model.WithLRULimit(lruLimit),
model.WithMemoryReclaimer(options.MemoryReclaimerEnabled, options.MemoryReclaimerThreshold),
model.WithForceEvictionWhenBusy(options.ForceEvictionWhenBusy),
model.WithSizeAwareEviction(options.SizeAwareEviction),
)
application.ModelLoader().SetWatchDog(wd)
// Initialize ModelLoader LRU eviction retry settings
application.ModelLoader().SetLRUEvictionRetrySettings(
options.LRUEvictionMaxRetries,
options.LRUEvictionRetryInterval,
)
// Sync per-model state from configs to the watchdog. Without this,
// `pinned: true` and `concurrency_groups:` are only honored after a
// settings-driven RestartWatchdog and never at boot.
application.SyncPinnedModelsToWatchdog()
application.SyncModelGroupsToWatchdog()
// Start watchdog goroutine if any periodic checks are enabled
// LRU eviction doesn't need the Run() loop - it's triggered on model load
// But memory reclaimer needs the Run() loop for periodic checking
if options.WatchDogBusy || options.WatchDogIdle || options.MemoryReclaimerEnabled {
go wd.Run()
}
go func() {
<-options.Context.Done()
xlog.Debug("Context canceled, shutting down")
wd.Shutdown()
}()
}
}
// loadOrGenerateHMACSecret loads an HMAC secret from the given file path,
// or generates a random 32-byte secret and persists it if the file doesn't exist.
func loadOrGenerateHMACSecret(path string) (string, error) {
data, err := os.ReadFile(path)
if err == nil {
secret := string(data)
if len(secret) >= 32 {
return secret, nil
}
}
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to generate HMAC secret: %w", err)
}
secret := hex.EncodeToString(b)
if err := os.WriteFile(path, []byte(secret), 0o600); err != nil {
return "", fmt.Errorf("failed to persist HMAC secret: %w", err)
}
xlog.Info("Generated new HMAC secret for API key hashing", "path", path)
return secret, nil
}
// migrateDataFiles moves persistent data files from the old config directory
// to the new data directory. Only moves files that exist in src but not in dst.
func migrateDataFiles(srcDir, dstDir string) {
// Files and directories to migrate
items := []string{
"agent_tasks.json",
"agent_jobs.json",
"collections",
"assets",
}
migrated := false
for _, item := range items {
srcPath := filepath.Join(srcDir, item)
dstPath := filepath.Join(dstDir, item)
// Only migrate if source exists and destination does not
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
continue
}
if _, err := os.Stat(dstPath); err == nil {
continue // destination already exists, skip
}
if err := os.Rename(srcPath, dstPath); err != nil {
xlog.Warn("Failed to migrate data file, will copy instead", "src", srcPath, "dst", dstPath, "error", err)
// os.Rename fails across filesystems, fall back to leaving in place
// and log a warning for the user to manually move
xlog.Warn("Data file remains in old location, please move manually", "src", srcPath, "dst", dstPath)
continue
}
migrated = true
xlog.Info("Migrated data file to new data path", "src", srcPath, "dst", dstPath)
}
if migrated {
xlog.Info("Data migration complete", "from", srcDir, "to", dstDir)
}
}
// registerFailoverMetrics must not retain a manager on the global provider
// when this application has metrics disabled.
func (a *Application) registerFailoverMetrics() {
if a.applicationConfig.DisableMetrics || a.metricsService == nil {
return
}
failover.RegisterMetrics(a.failoverManager, a.metricsService.Meter)
}