fix(failover): pass credential lookup from CLI

Pass the API key environment lookup through ApplicationConfig to satisfy
core configuration lint. Keep credential resolution dynamic and exclude
the callback from serialization.

Handle the five close results reported by errcheck.

Assisted-by: Codex:gpt-6 golangci-lint
This commit is contained in:
localai-org-maint-bot committed 2026-09-26 22:06:23 +00:00
1 parent 035288e5f8
commit 19f6a18b80
9 files changed
+44 -24

No files matched your search

+1 -1
View File
@@ -255,7 +255,7 @@ func New(opts ...config.AppOption) (*Application, error) {
// chain. WithOnWarmChanged pins and preloads warm local targets so a // chain. WithOnWarmChanged pins and preloads warm local targets so a
// switch to them does not wait for a cold load. // switch to them does not wait for a cold load.
application.failoverManager = failover.New(application.ModelConfigLoader(), application.failoverManager = failover.New(application.ModelConfigLoader(),
failover.WithProber(failover.NewProber(failoverLoadedBackend(application.ModelLoader()))), failover.WithProber(failover.NewProber(failoverLoadedBackend(application.ModelLoader()), options.ProxyAPIKeyEnvLookup)),
failover.WithOnWarmChanged(application.applyFailoverWarmTargets), failover.WithOnWarmChanged(application.applyFailoverWarmTargets),
) )
// The assistant client was built in start() (above), before this // The assistant client was built in start() (above), before this
+1
View File
@@ -291,6 +291,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
} }
opts := []config.AppOption{ opts := []config.AppOption{
config.WithProxyAPIKeyEnvLookup(os.Getenv),
config.WithContext(context.Background()), config.WithContext(context.Background()),
config.WithArtifactDownloadConcurrency(r.ArtifactDownloadConcurrency), config.WithArtifactDownloadConcurrency(r.ArtifactDownloadConcurrency),
config.WithModelArtifactMaterializer(modelartifacts.NewDefaultManager( config.WithModelArtifactMaterializer(modelartifacts.NewDefaultManager(
+7
View File
@@ -14,6 +14,9 @@ import (
) )
type ApplicationConfig struct { type ApplicationConfig struct {
// ProxyAPIKeyEnvLookup resolves upstream credentials at the CLI boundary.
ProxyAPIKeyEnvLookup func(string) string `json:"-" yaml:"-"`
Context context.Context Context context.Context
ConfigFile string ConfigFile string
SystemState *system.SystemState SystemState *system.SystemState
@@ -271,6 +274,10 @@ type AgentPoolConfig struct {
type AppOption func(*ApplicationConfig) type AppOption func(*ApplicationConfig)
func WithProxyAPIKeyEnvLookup(lookup func(string) string) AppOption {
return func(o *ApplicationConfig) { o.ProxyAPIKeyEnvLookup = lookup }
}
func NewApplicationConfig(o ...AppOption) *ApplicationConfig { func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
opt := &ApplicationConfig{ opt := &ApplicationConfig{
Context: context.Background(), Context: context.Background(),
+5 -2
View File
@@ -306,10 +306,13 @@ const (
// "" when neither is set. Mirrored (not imported, to keep backends independent // "" when neither is set. Mirrored (not imported, to keep backends independent
// of core's package layout) by resolveAPIKey in backend/go/cloud-proxy/proxy.go // of core's package layout) by resolveAPIKey in backend/go/cloud-proxy/proxy.go
// — keep the two in sync, empty-value handling included. // — keep the two in sync, empty-value handling included.
func (p ProxyConfig) ResolveAPIKey() (string, error) { func (p ProxyConfig) ResolveAPIKey(envLookup func(string) string) (string, error) {
switch { switch {
case p.APIKeyEnv != "": case p.APIKeyEnv != "":
v := os.Getenv(p.APIKeyEnv) var v string
if envLookup != nil {
v = envLookup(p.APIKeyEnv)
}
if v == "" { if v == "" {
return "", fmt.Errorf("proxy api_key_env %q is unset", p.APIKeyEnv) return "", fmt.Errorf("proxy api_key_env %q is unset", p.APIKeyEnv)
} }
+14 -7
View File
@@ -70,25 +70,32 @@ failover:
}) })
var _ = Describe("ProxyConfig.ResolveAPIKey", func() { var _ = Describe("ProxyConfig.ResolveAPIKey", func() {
It("reads the env var", func() { It("reads the key through the application lookup", func() {
GinkgoT().Setenv("FAILOVER_TEST_KEY", "k1") app := NewApplicationConfig(WithProxyAPIKeyEnvLookup(func(name string) string {
Expect(ProxyConfig{APIKeyEnv: "FAILOVER_TEST_KEY"}.ResolveAPIKey()).To(Equal("k1")) Expect(name).To(Equal("FAILOVER_TEST_KEY"))
return "k1"
}))
Expect(ProxyConfig{APIKeyEnv: "FAILOVER_TEST_KEY"}.ResolveAPIKey(app.ProxyAPIKeyEnvLookup)).To(Equal("k1"))
})
It("fails without an environment lookup", func() {
_, err := ProxyConfig{APIKeyEnv: "FAILOVER_TEST_KEY"}.ResolveAPIKey(nil)
Expect(err).To(HaveOccurred())
}) })
It("fails on an unset env var", func() { It("fails on an unset env var", func() {
_, err := ProxyConfig{APIKeyEnv: "FAILOVER_TEST_UNSET_KEY"}.ResolveAPIKey() _, err := ProxyConfig{APIKeyEnv: "FAILOVER_TEST_UNSET_KEY"}.ResolveAPIKey(os.Getenv)
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
}) })
It("fails on a set-but-empty env var", func() { It("fails on a set-but-empty env var", func() {
GinkgoT().Setenv("FAILOVER_TEST_EMPTY_KEY", "") GinkgoT().Setenv("FAILOVER_TEST_EMPTY_KEY", "")
_, err := ProxyConfig{APIKeyEnv: "FAILOVER_TEST_EMPTY_KEY"}.ResolveAPIKey() _, err := ProxyConfig{APIKeyEnv: "FAILOVER_TEST_EMPTY_KEY"}.ResolveAPIKey(os.Getenv)
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
}) })
It("reads and trims the key file", func() { It("reads and trims the key file", func() {
f := filepath.Join(GinkgoT().TempDir(), "key") f := filepath.Join(GinkgoT().TempDir(), "key")
Expect(os.WriteFile(f, []byte(" k2\n"), 0o600)).To(Succeed()) Expect(os.WriteFile(f, []byte(" k2\n"), 0o600)).To(Succeed())
Expect(ProxyConfig{APIKeyFile: f}.ResolveAPIKey()).To(Equal("k2")) Expect(ProxyConfig{APIKeyFile: f}.ResolveAPIKey(os.Getenv)).To(Equal("k2"))
}) })
It("returns empty when nothing is set", func() { It("returns empty when nothing is set", func() {
Expect(ProxyConfig{}.ResolveAPIKey()).To(Equal("")) Expect(ProxyConfig{}.ResolveAPIKey(os.Getenv)).To(Equal(""))
}) })
}) })
+1 -1
View File
@@ -91,7 +91,7 @@ var _ = Describe("failover endpoints", func() {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/api/failover/events", nil) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/api/failover/events", nil)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
Expect(resp.Header.Get("Content-Type")).To(HavePrefix("text/event-stream")) Expect(resp.Header.Get("Content-Type")).To(HavePrefix("text/event-stream"))
r := bufio.NewReader(resp.Body) r := bufio.NewReader(resp.Body)
next := func() string { next := func() string {
+8 -7
View File
@@ -32,17 +32,18 @@ type LoadedFunc func(cfg config.ModelConfig) grpc.Backend
// DefaultProber probes remote targets over the upstream's OpenAI-compatible // DefaultProber probes remote targets over the upstream's OpenAI-compatible
// API and local targets through their gRPC backend. // API and local targets through their gRPC backend.
type DefaultProber struct { type DefaultProber struct {
HTTP *http.Client HTTP *http.Client
Loaded LoadedFunc Loaded LoadedFunc
EnvLookup func(string) string
} }
func NewProber(loaded LoadedFunc) *DefaultProber { func NewProber(loaded LoadedFunc, envLookup func(string) string) *DefaultProber {
return &DefaultProber{HTTP: &http.Client{ return &DefaultProber{HTTP: &http.Client{
// A redirect is a failed probe, not something to follow: Go resends // A redirect is a failed probe, not something to follow: Go resends
// custom headers such as x-api-key to any host, and the target's // custom headers such as x-api-key to any host, and the target's
// API key must reach only the configured upstream. // API key must reach only the configured upstream.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}, Loaded: loaded} }, Loaded: loaded, EnvLookup: envLookup}
} }
func (p *DefaultProber) Liveness(ctx context.Context, cfg config.ModelConfig, kind Kind, warm bool) error { func (p *DefaultProber) Liveness(ctx context.Context, cfg config.ModelConfig, kind Kind, warm bool) error {
@@ -102,7 +103,7 @@ func PrepareTarget(cfg *config.ModelConfig) {
} }
func (p *DefaultProber) authorize(req *http.Request, cfg config.ModelConfig) error { func (p *DefaultProber) authorize(req *http.Request, cfg config.ModelConfig) error {
key, err := cfg.Proxy.ResolveAPIKey() key, err := cfg.Proxy.ResolveAPIKey(p.EnvLookup)
if err != nil || key == "" { if err != nil || key == "" {
return err return err
} }
@@ -124,7 +125,7 @@ func (p *DefaultProber) do(req *http.Request, cfg config.ModelConfig) (*http.Res
return nil, err return nil, err
} }
if resp.StatusCode/100 != 2 { if resp.StatusCode/100 != 2 {
resp.Body.Close() _ = resp.Body.Close()
return nil, fmt.Errorf("upstream %s: HTTP %d", req.URL.Path, resp.StatusCode) return nil, fmt.Errorf("upstream %s: HTTP %d", req.URL.Path, resp.StatusCode)
} }
return resp, nil return resp, nil
@@ -143,7 +144,7 @@ func (p *DefaultProber) remoteLiveness(ctx context.Context, cfg config.ModelConf
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
var list struct { var list struct {
Data []struct { Data []struct {
ID string `json:"id"` ID string `json:"id"`
+5 -4
View File
@@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"sync" "sync"
"github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/config"
@@ -75,7 +76,7 @@ var _ = Describe("DefaultProber", func() {
BeforeEach(func() { BeforeEach(func() {
up = newFakeUpstream() up = newFakeUpstream()
DeferCleanup(up.srv.Close) DeferCleanup(up.srv.Close)
p = NewProber(nil) p = NewProber(nil, os.Getenv)
}) })
proxied := func(name, upstreamModel string, usecases ...string) config.ModelConfig { proxied := func(name, upstreamModel string, usecases ...string) config.ModelConfig {
@@ -155,7 +156,7 @@ var _ = Describe("DefaultProber", func() {
It("uses HealthCheck for warm local liveness and Predict for local chat inference", func() { It("uses HealthCheck for warm local liveness and Predict for local chat inference", func() {
b := &fakeBackend{healthy: true} b := &fakeBackend{healthy: true}
p = NewProber(func(config.ModelConfig) grpc.Backend { return b }) p = NewProber(func(config.ModelConfig) grpc.Backend { return b }, nil)
c := config.ModelConfig{Name: "gemma", Backend: "llama-cpp", KnownUsecaseStrings: []string{"chat"}} c := config.ModelConfig{Name: "gemma", Backend: "llama-cpp", KnownUsecaseStrings: []string{"chat"}}
c.KnownUsecases = config.GetUsecasesFromYAML(c.KnownUsecaseStrings) c.KnownUsecases = config.GetUsecasesFromYAML(c.KnownUsecaseStrings)
Expect(p.Liveness(ctx, c, KindLocal, true)).To(Succeed()) Expect(p.Liveness(ctx, c, KindLocal, true)).To(Succeed())
@@ -171,7 +172,7 @@ var _ = Describe("DefaultProber", func() {
// The warm preload loads the model; a probe that loaded it too would // The warm preload loads the model; a probe that loaded it too would
// block until the load finished and then judge it on an expired ctx. // block until the load finished and then judge it on an expired ctx.
asked := 0 asked := 0
p = NewProber(func(config.ModelConfig) grpc.Backend { asked++; return nil }) p = NewProber(func(config.ModelConfig) grpc.Backend { asked++; return nil }, nil)
for _, uc := range []string{"chat", "tts"} { for _, uc := range []string{"chat", "tts"} {
c := config.ModelConfig{Name: "gemma", Backend: "llama-cpp", KnownUsecaseStrings: []string{uc}} c := config.ModelConfig{Name: "gemma", Backend: "llama-cpp", KnownUsecaseStrings: []string{uc}}
c.KnownUsecases = config.GetUsecasesFromYAML(c.KnownUsecaseStrings) c.KnownUsecases = config.GetUsecasesFromYAML(c.KnownUsecaseStrings)
@@ -185,7 +186,7 @@ var _ = Describe("DefaultProber", func() {
p = NewProber(func(config.ModelConfig) grpc.Backend { p = NewProber(func(config.ModelConfig) grpc.Backend {
Fail("cold liveness must not look up the backend") Fail("cold liveness must not look up the backend")
return nil return nil
}) }, nil)
// None of these files exist: a missing file says nothing about whether // None of these files exist: a missing file says nothing about whether
// the target can serve (download on first use, dotted names, backends // the target can serve (download on first use, dotted names, backends
// that need no file). Only a real request may trip a cold target. // that need no file). Only a real request may trip a cold target.
+2 -2
View File
@@ -237,7 +237,7 @@ var _ = Describe("Realtime WebSocket API", Label("Realtime"), func() {
It("switches the LLM mid-session and keeps the conversation", func() { It("switches the LLM mid-session and keeps the conversation", func() {
conn := connectWS("rt-failover") conn := connectWS("rt-failover")
defer conn.Close() defer func() { _ = conn.Close() }()
Expect(readServerEvent(conn, 30*time.Second)["type"]).To(Equal("session.created")) Expect(readServerEvent(conn, 30*time.Second)["type"]).To(Equal("session.created"))
initial := drainUntil(conn, "localai.model.failover", 10*time.Second) initial := drainUntil(conn, "localai.model.failover", 10*time.Second)
@@ -275,7 +275,7 @@ var _ = Describe("Realtime WebSocket API", Label("Realtime"), func() {
It("starts the session on the next target when the active one fails to warm up", func() { It("starts the session on the next target when the active one fails to warm up", func() {
conn := connectWS("rt-failover-warm") conn := connectWS("rt-failover-warm")
defer conn.Close() defer func() { _ = conn.Close() }()
Expect(readServerEvent(conn, 30*time.Second)["type"]).To(Equal("session.created")) Expect(readServerEvent(conn, 30*time.Second)["type"]).To(Equal("session.created"))
initial := drainUntil(conn, "localai.model.failover", 10*time.Second) initial := drainUntil(conn, "localai.model.failover", 10*time.Second)