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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-09-27 07:42:21 +00:00
1 parent e61bd2d78e
commit fe8519d7b5
2 files changed
+28 -2

No files matched your search

+13 -2
View File
@@ -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
}
+15
View File
@@ -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"))