mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-30 10:04:32 -04:00
The e2e suite now registers the localai-proxy binary and points proxy models back at the test server itself, so a request leaves LocalAI through the backend, returns over REST and is answered by a mock model. Chat, embeddings, TTS and transcription through the proxy return the upstream model's answer; a chain whose proxy target's upstream model fails to load serves from the local target; and a realtime pipeline whose LLM stage is a chain on a remote target completes a turn, then switches to the local target with a localai.model.failover trip event when a gate in front of the upstream starts answering 503. The docs describe the localai-proxy backend next to cloud-proxy and add a per-stage remote LocalAI example to the failover page. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
175 lines
6.7 KiB
Go
175 lines
6.7 KiB
Go
package e2e_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"time"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Failover chains", Label("failover"), func() {
|
|
expectServedByMock := func(resp *http.Response) {
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
Expect(resp.StatusCode).To(BeNumerically("<", 300), "%s\nheaders: %v", body, resp.Header)
|
|
Expect(resp.Header.Get("X-LocalAI-Served-Model")).To(Equal("mock-model"))
|
|
Expect(resp.Header.Get("X-LocalAI-Failover")).To(Equal("fallback"))
|
|
}
|
|
|
|
// The entry name is the chain suffix: chain-<name> is written by the suite.
|
|
DescribeTable("retries every endpoint family on the next target",
|
|
func(path string, body func(model string) map[string]any) {
|
|
expectServedByMock(postJSONTo(path, body("chain-"+CurrentSpecReport().LeafNodeText)))
|
|
},
|
|
Entry("chat", "/chat/completions", func(m string) map[string]any {
|
|
return map[string]any{"model": m, "messages": []map[string]string{{"role": "user", "content": "hi"}}}
|
|
}),
|
|
Entry("completion", "/completions", func(m string) map[string]any {
|
|
return map[string]any{"model": m, "prompt": "hi"}
|
|
}),
|
|
Entry("embeddings", "/embeddings", func(m string) map[string]any {
|
|
return map[string]any{"model": m, "input": "hi"}
|
|
}),
|
|
Entry("tts", "/audio/speech", func(m string) map[string]any {
|
|
return map[string]any{"model": m, "input": "hi", "voice": "default"}
|
|
}),
|
|
Entry("image", "/images/generations", func(m string) map[string]any {
|
|
return map[string]any{"model": m, "prompt": "a cat", "size": "256x256"}
|
|
}),
|
|
Entry("rerank", "/rerank", func(m string) map[string]any {
|
|
return map[string]any{"model": m, "query": "q", "documents": []string{"a", "b"}}
|
|
}),
|
|
Entry("vad", "/vad", func(m string) map[string]any {
|
|
return map[string]any{"model": m, "audio": []float32{0, 0, 0, 0}}
|
|
}),
|
|
)
|
|
|
|
It("retries transcription with the multipart body", func() {
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
Expect(mw.WriteField("model", "chain-transcription")).To(Succeed())
|
|
fw, err := mw.CreateFormFile("file", "a.wav")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// 200 ms of 16 kHz mono silence.
|
|
_, err = fw.Write(wavFromPCM(make([]byte, 6400), 16000))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mw.Close()).To(Succeed())
|
|
resp, err := http.Post(apiURL+"/audio/transcriptions", mw.FormDataContentType(), &body)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
expectServedByMock(resp)
|
|
})
|
|
|
|
Describe("remote targets", Ordered, func() {
|
|
var up1, up2 *fakeOpenAIUpstreamServer
|
|
|
|
chatReply := func([]byte) (int, string, string) {
|
|
return 200, `{"id":"x","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}`, "application/json"
|
|
}
|
|
|
|
BeforeAll(func() {
|
|
if cloudProxyPath == "" {
|
|
Skip("cloud-proxy backend binary not built (make build-cloud-proxy-backend)")
|
|
}
|
|
up1, up2 = newFakeOpenAIUpstream(), newFakeOpenAIUpstream()
|
|
DeferCleanup(up1.Close)
|
|
DeferCleanup(up2.Close)
|
|
up1.SetModels("up-1")
|
|
up2.SetModels("up-2")
|
|
registerFailoverRemoteModels(up1.URL(), up2.URL())
|
|
})
|
|
|
|
It("fails over when the primary upstream errors and fails back when it recovers", func() {
|
|
up1.SetScript(func([]byte) (int, string, string) {
|
|
return 503, `{"error":"no healthy nodes"}`, "application/json"
|
|
})
|
|
up2.SetScript(chatReply)
|
|
|
|
resp := postJSONTo("/chat/completions", map[string]any{"model": "chain-remote", "messages": []map[string]string{{"role": "user", "content": "hi"}}})
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
Expect(resp.StatusCode).To(Equal(200), string(body))
|
|
Expect(resp.Header.Get("X-LocalAI-Served-Model")).To(Equal("up-2"))
|
|
Expect(resp.Header.Get("X-LocalAI-Failover")).To(Equal("fallback"))
|
|
Expect(chainActive("chain-remote")).To(Equal("up-2"))
|
|
// The targets set no upstream_model: each upstream must get its
|
|
// target's name (what the liveness probe checks), not the chain
|
|
// name the client sent. up-2 is healthy, so only this request
|
|
// posted to it.
|
|
Expect(upstreamBodyModel(up2)).To(Equal("up-2"))
|
|
|
|
up1.SetScript(chatReply)
|
|
Eventually(func() string { return chainActive("chain-remote") }, 30*time.Second, 500*time.Millisecond).
|
|
Should(Equal("up-1"))
|
|
|
|
resp2 := postJSONTo("/chat/completions", map[string]any{"model": "chain-remote", "messages": []map[string]string{{"role": "user", "content": "hi"}}})
|
|
defer func() { _ = resp2.Body.Close() }()
|
|
Expect(resp2.StatusCode).To(Equal(200))
|
|
Expect(resp2.Header.Get("X-LocalAI-Served-Model")).To(Equal("up-1"))
|
|
Expect(resp2.Header.Get("X-LocalAI-Failover")).To(BeEmpty())
|
|
Expect(upstreamBodyModel(up1)).To(Equal("up-1"))
|
|
})
|
|
})
|
|
})
|
|
|
|
// upstreamBodyModel returns the "model" field of the last request body the
|
|
// fake upstream recorded.
|
|
func upstreamBodyModel(up *fakeOpenAIUpstreamServer) string {
|
|
_, _, _, body := up.recorder.snapshot()
|
|
var req struct {
|
|
Model string `json:"model"`
|
|
}
|
|
Expect(json.Unmarshal(body, &req)).To(Succeed(), string(body))
|
|
return req.Model
|
|
}
|
|
|
|
// chainActive returns the active target of a chain as the REST status reports
|
|
// it, or "" when the status cannot be read.
|
|
func chainActive(chain string) string {
|
|
r, err := http.Get(anthropicBaseURL + "/api/failover/" + chain)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer func() { _ = r.Body.Close() }()
|
|
var st struct {
|
|
Active string `json:"active"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&st)
|
|
return st.Active
|
|
}
|
|
|
|
// registerFailoverRemoteModels registers two cloud-proxy passthrough models
|
|
// (up-1, up-2) and a chain over them. The upstream URLs exist only at runtime,
|
|
// so the YAMLs are written after startup and the loader re-reads the models
|
|
// directory; the failover manager picks the chain up on its next tick.
|
|
func registerFailoverRemoteModels(url1, url2 string) {
|
|
proxyModel := func(name, upstream string) map[string]any {
|
|
return map[string]any{
|
|
"name": name,
|
|
"backend": "cloud-proxy",
|
|
"parameters": map[string]any{"model": name + ".bin"},
|
|
"proxy": map[string]any{
|
|
"mode": "passthrough",
|
|
"provider": "openai",
|
|
"upstream_url": upstream + "/v1/chat/completions",
|
|
"api_key_env": "CLOUD_PROXY_E2E_OPENAI_KEY",
|
|
},
|
|
}
|
|
}
|
|
chain := map[string]any{
|
|
"name": "chain-remote",
|
|
"failover": map[string]any{
|
|
"targets": []map[string]any{{"model": "up-1"}, {"model": "up-2"}},
|
|
"probe": map[string]any{"interval": "1s"},
|
|
"recovery": map[string]any{"probes": 2, "min_dwell": "2s"},
|
|
},
|
|
}
|
|
registerModelConfigs(proxyModel("up-1", url1), proxyModel("up-2", url2), chain)
|
|
Eventually(func() string { return chainActive("chain-remote") }, 10*time.Second, 200*time.Millisecond).
|
|
Should(Equal("up-1"))
|
|
}
|