From fe8519d7b5b55e63d34fa761a216ee40b9f7b404 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 27 Sep 2026 02:29:36 +0000 Subject: [PATCH] fix(localai-proxy): treat upstream_url as the server root, ignoring /v1 The failover prober cuts upstream_url at /v1, but Load kept the path, so an upstream_url ending in /v1 probed healthy while every request went to /v1/v1/... and got a 404, which never trips the target. Cut the path at /v1 in Load too, with a warning. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Ettore Di Giacinto --- backend/go/localai-proxy/proxy.go | 15 +++++++++++++-- backend/go/localai-proxy/text_test.go | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/go/localai-proxy/proxy.go b/backend/go/localai-proxy/proxy.go index 8fcd715f3..aa205d27c 100644 --- a/backend/go/localai-proxy/proxy.go +++ b/backend/go/localai-proxy/proxy.go @@ -74,6 +74,17 @@ func (p *LocalAIProxy) Load(opts *pb.ModelOptions) error { return fmt.Errorf("localai-proxy: proxy.upstream_url %q must be an http(s) URL with a host", raw) } + // Every request path starts with /v1, so upstream_url is the server's + // root. A URL copied from an OpenAI-style config ends in /v1 (or a full + // endpoint): cut the path at /v1 as the failover prober does, or the + // prober reports the target healthy while every request 404s. + base := strings.TrimRight(raw, "/") + if i := strings.Index(u.Path, "/v1"); i >= 0 { + base = strings.TrimRight(u.Scheme+"://"+u.Host+u.Path[:i], "/") + xlog.Warn("localai-proxy: proxy.upstream_url should be the server root; ignoring its /v1 path", + "upstream_url", raw, "using", base) + } + // There is no translate mode: the upstream always speaks LocalAI's API. if po.GetMode() != "" || po.GetProvider() != "" { xlog.Warn("localai-proxy: proxy.mode and proxy.provider are ignored", @@ -106,13 +117,13 @@ func (p *LocalAIProxy) Load(opts *pb.ModelOptions) error { } p.cfg.Store(&proxyConfig{ - base: strings.TrimRight(raw, "/"), + base: base, upstreamModel: model, apiKey: key, realtimePipeline: pipeline, timeout: timeout, }) - xlog.Info("localai-proxy: ready", "upstream", raw, "upstream_model", model, + xlog.Info("localai-proxy: ready", "upstream", base, "upstream_model", model, "has_key", key != "", "realtime_pipeline", pipeline) return nil } diff --git a/backend/go/localai-proxy/text_test.go b/backend/go/localai-proxy/text_test.go index 53ad73c9b..5039f41bd 100644 --- a/backend/go/localai-proxy/text_test.go +++ b/backend/go/localai-proxy/text_test.go @@ -63,6 +63,21 @@ var _ = Describe("localai-proxy", func() { Expect(cfg.timeout).To(Equal(7 * time.Second)) }) + DescribeTable("strips an OpenAI-style /v1 suffix, as the failover prober does", + func(suffix string) { + p := loadProxy(up, func(o *pb.ModelOptions) { o.Proxy.UpstreamUrl = up.URL + suffix }) + Expect(p.cfg.Load().base).To(Equal(up.URL)) + }, + Entry("/v1", "/v1"), + Entry("/v1/", "/v1/"), + Entry("a full endpoint path", "/v1/chat/completions"), + ) + + It("keeps a path prefix in front of /v1", func() { + p := loadProxy(up, func(o *pb.ModelOptions) { o.Proxy.UpstreamUrl = up.URL + "/localai/v1" }) + Expect(p.cfg.Load().base).To(Equal(up.URL + "/localai")) + }) + It("falls back to the model name when upstream_model is unset", func() { p := loadProxy(up, func(o *pb.ModelOptions) { o.Proxy.UpstreamModel = "" }) Expect(p.model("")).To(Equal("local-name"))