mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-15 15:52:31 -04:00
* feat(cli): benchmark configured text models Compare model aliases through a running LocalAI server. Report full request latency and server-reported throughput with raw JSON samples. Keep warmups separate and fail before writing results on request errors. Assisted-by: Codex:GPT-6 * fix(cli): satisfy benchmark error checks Explicitly discard errors from buffered report writes, HTTP response cleanup, and test server writes to pass errcheck without changing behavior. Assisted-by: Codex:gpt-6 golangci-lint --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
243 lines
11 KiB
Go
243 lines
11 KiB
Go
// SPDX-License-Identifier: MIT
|
|
package benchmark
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alecthomas/kong"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
func TestBenchmark(t *testing.T) { RegisterFailHandler(Fail); RunSpecs(t, "Benchmark") }
|
|
|
|
var _ = Describe("Benchmark command", func() {
|
|
var cmd Command
|
|
var output bytes.Buffer
|
|
BeforeEach(func() {
|
|
cmd = Command{Models: []string{"a"}, Endpoint: "http://127.0.0.1:8080", Prompt: "hello", MaxTokens: 128, Runs: 2, Warmup: 1, Timeout: time.Second, JSON: true}
|
|
output.Reset()
|
|
})
|
|
It("parses required models and defaults", func() {
|
|
var c Command
|
|
parser, err := kong.New(&c)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_, err = parser.Parse(nil)
|
|
Expect(err).To(HaveOccurred())
|
|
_, err = parser.Parse([]string{"a", "b"})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(c.Models).To(Equal([]string{"a", "b"}))
|
|
Expect(c.Endpoint).To(Equal("http://127.0.0.1:8080"))
|
|
Expect(c.Runs).To(Equal(3))
|
|
Expect(c.Warmup).To(Equal(1))
|
|
Expect(c.MaxTokens).To(Equal(128))
|
|
Expect(c.Timeout).To(Equal(5 * time.Minute))
|
|
Expect(c.Prompt).NotTo(BeEmpty())
|
|
})
|
|
It("reads API key environment variables in priority order", func() {
|
|
for _, key := range []string{"LOCALAI_API_KEY", "API_KEY"} {
|
|
value, present := os.LookupEnv(key)
|
|
DeferCleanup(func() {
|
|
if present {
|
|
Expect(os.Setenv(key, value)).To(Succeed())
|
|
} else {
|
|
Expect(os.Unsetenv(key)).To(Succeed())
|
|
}
|
|
})
|
|
}
|
|
Expect(os.Unsetenv("LOCALAI_API_KEY")).To(Succeed())
|
|
Expect(os.Setenv("API_KEY", "fallback")).To(Succeed())
|
|
var c Command
|
|
parser, err := kong.New(&c)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_, err = parser.Parse([]string{"a"})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(c.APIKey).To(Equal("fallback"))
|
|
Expect(os.Setenv("LOCALAI_API_KEY", "preferred")).To(Succeed())
|
|
_, err = parser.Parse([]string{"a"})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(c.APIKey).To(Equal("preferred"))
|
|
})
|
|
DescribeTable("normalizes endpoints", func(input, expected string) {
|
|
actual, err := completionURL(input)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(actual).To(Equal(expected))
|
|
},
|
|
Entry("root", "http://localhost:8080", "http://localhost:8080/v1/chat/completions"), Entry("slash", "http://localhost:8080/", "http://localhost:8080/v1/chat/completions"), Entry("v1", "https://example.org/v1/", "https://example.org/v1/chat/completions"), Entry("proxy", "https://example.org/proxy/", "https://example.org/proxy/v1/chat/completions"), Entry("proxy v1", "https://example.org/proxy/v1", "https://example.org/proxy/v1/chat/completions"))
|
|
It("posts authenticated requests sequentially and excludes each model's warmup", func() {
|
|
var models []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer GinkgoRecover()
|
|
Expect(r.Method).To(Equal("POST"))
|
|
Expect(r.URL.Path).To(Equal("/proxy/v1/chat/completions"))
|
|
Expect(r.Header.Get("Authorization")).To(Equal("Bearer secret"))
|
|
Expect(r.Header.Get("Content-Type")).To(Equal("application/json"))
|
|
var body map[string]any
|
|
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
|
|
Expect(body["temperature"]).To(Equal(float64(0)))
|
|
Expect(body["stream"]).To(BeFalse())
|
|
Expect(body["max_tokens"]).To(Equal(float64(128)))
|
|
Expect(body["messages"]).To(Equal([]any{map[string]any{"role": "user", "content": "hello"}}))
|
|
models = append(models, body["model"].(string))
|
|
_, _ = fmt.Fprintf(w, `{"choices":[{}],"usage":{"prompt_tokens":5,"completion_tokens":%d}}`, len(models))
|
|
}))
|
|
defer server.Close()
|
|
cmd.Endpoint = server.URL + "/proxy"
|
|
cmd.APIKey = "secret"
|
|
cmd.Models = []string{"a", "b"}
|
|
Expect(cmd.run(context.Background(), &output)).To(Succeed())
|
|
Expect(models).To(Equal([]string{"a", "a", "a", "b", "b", "b"}))
|
|
Expect(output.String()).NotTo(ContainSubstring("secret"))
|
|
var result report
|
|
Expect(json.Unmarshal(output.Bytes(), &result)).To(Succeed())
|
|
Expect(result.Results).To(HaveLen(2))
|
|
Expect(result.Settings.Runs).To(Equal(2))
|
|
Expect(result.Settings.Warmup).To(Equal(1))
|
|
Expect(result.Settings.Prompt).To(Equal("hello"))
|
|
Expect(result.Settings.Temperature).To(BeZero())
|
|
Expect(result.Settings.Stream).To(BeFalse())
|
|
first := result.Results[0]
|
|
Expect(first.Samples).To(HaveLen(2))
|
|
Expect(*first.Samples[0].CompletionTokens).To(Equal(2))
|
|
Expect(*first.Samples[1].CompletionTokens).To(Equal(3))
|
|
Expect(*first.Samples[0].PromptTokens).To(Equal(5))
|
|
Expect(first.MinSeconds).To(BeNumerically(">", 0))
|
|
Expect(first.MeanSeconds).To(BeNumerically(">=", first.MinSeconds))
|
|
Expect(first.MaxSeconds).To(BeNumerically(">=", first.MeanSeconds))
|
|
Expect(*first.CompletionTokensPerSecond).To(BeNumerically("~", 5/(first.Samples[0].LatencySeconds+first.Samples[1].LatencySeconds), 0.001))
|
|
})
|
|
DescribeTable("preserves missing and zero usage", func(usage string, available bool) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprint(w, `{"choices":[{}]`+usage+`}`) }))
|
|
defer server.Close()
|
|
cmd.Endpoint = server.URL
|
|
cmd.Warmup = 0
|
|
Expect(cmd.run(context.Background(), &output)).To(Succeed())
|
|
var result report
|
|
Expect(json.Unmarshal(output.Bytes(), &result)).To(Succeed())
|
|
if available {
|
|
Expect(*result.Results[0].CompletionTokensPerSecond).To(BeZero())
|
|
} else {
|
|
Expect(result.Results[0].CompletionTokensPerSecond).To(BeNil())
|
|
}
|
|
cmd.JSON = false
|
|
output.Reset()
|
|
Expect(cmd.run(context.Background(), &output)).To(Succeed())
|
|
if !available {
|
|
Expect(output.String()).To(ContainSubstring("N/A"))
|
|
}
|
|
}, Entry("absent", "", false), Entry("empty", `,"usage":{}`, false), Entry("partial", `,"usage":{"prompt_tokens":0}`, false), Entry("zero", `,"usage":{"prompt_tokens":0,"completion_tokens":0}`, true))
|
|
It("retains API error details while redacting the key", func() {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = fmt.Fprint(w, `{"error":{"message":"model unavailable: secret"}}`)
|
|
}))
|
|
defer server.Close()
|
|
cmd.Endpoint = server.URL
|
|
cmd.APIKey = "secret"
|
|
cmd.Warmup = 0
|
|
err := cmd.run(context.Background(), &output)
|
|
Expect(err).To(MatchError(ContainSubstring(`model "a" run 1: server returned an API error: model unavailable: [redacted]`)))
|
|
Expect(output.Len()).To(BeZero())
|
|
})
|
|
It("marks throughput unavailable when one measured request omits usage", func() {
|
|
requests := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
if requests == 1 {
|
|
_, _ = fmt.Fprint(w, `{"choices":[{}],"usage":{"completion_tokens":2}}`)
|
|
} else {
|
|
_, _ = fmt.Fprint(w, `{"choices":[{}]}`)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
cmd.Endpoint = server.URL
|
|
cmd.Warmup = 0
|
|
Expect(cmd.run(context.Background(), &output)).To(Succeed())
|
|
var result report
|
|
Expect(json.Unmarshal(output.Bytes(), &result)).To(Succeed())
|
|
Expect(result.Results[0].CompletionTokensPerSecond).To(BeNil())
|
|
Expect(result.Results[0].Samples[1].CompletionTokens).To(BeNil())
|
|
})
|
|
DescribeTable("fails without result output", func(status int, body string) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status); _, _ = fmt.Fprint(w, body) }))
|
|
defer server.Close()
|
|
cmd.Endpoint = server.URL
|
|
cmd.APIKey = "secret"
|
|
err := cmd.run(context.Background(), &output)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring(`model "a" warmup 1`))
|
|
Expect(err.Error()).NotTo(ContainSubstring("secret"))
|
|
Expect(output.Len()).To(BeZero())
|
|
}, Entry("HTTP", 500, `secret`), Entry("API", 200, `{"error":{"message":"secret"}}`), Entry("JSON", 200, `invalid`), Entry("empty choices", 200, `{"choices":[]}`), Entry("trailing JSON", 200, `{"choices":[{}]} {}`), Entry("negative tokens", 200, `{"choices":[{}],"usage":{"completion_tokens":-1}}`))
|
|
It("refuses redirects", func() {
|
|
reached := false
|
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }))
|
|
defer target.Close()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
|
|
}))
|
|
defer server.Close()
|
|
cmd.Endpoint = server.URL
|
|
Expect(cmd.run(context.Background(), &output)).NotTo(Succeed())
|
|
Expect(reached).To(BeFalse())
|
|
Expect(output.Len()).To(BeZero())
|
|
})
|
|
It("honors cancellation", func() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
err := cmd.run(ctx, &output)
|
|
Expect(err).To(MatchError(ContainSubstring("context canceled")))
|
|
Expect(output.Len()).To(BeZero())
|
|
})
|
|
It("times out requests", func() {
|
|
release := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release }))
|
|
defer server.Close()
|
|
defer close(release)
|
|
cmd.Endpoint = server.URL
|
|
cmd.Timeout = 20 * time.Millisecond
|
|
Expect(cmd.run(context.Background(), &output)).NotTo(Succeed())
|
|
Expect(output.Len()).To(BeZero())
|
|
})
|
|
It("times out while reading a response body", func() {
|
|
release := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = fmt.Fprint(w, `{"choices":[`)
|
|
w.(http.Flusher).Flush()
|
|
<-release
|
|
}))
|
|
defer server.Close()
|
|
defer close(release)
|
|
cmd.Endpoint = server.URL
|
|
cmd.Timeout = 20 * time.Millisecond
|
|
Expect(cmd.run(context.Background(), &output)).To(MatchError(ContainSubstring("request timed out")))
|
|
Expect(output.Len()).To(BeZero())
|
|
})
|
|
It("cancels an active request", func() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cancel() }))
|
|
defer server.Close()
|
|
cmd.Endpoint = server.URL
|
|
Expect(cmd.run(ctx, &output)).To(MatchError(ContainSubstring("context canceled")))
|
|
Expect(output.Len()).To(BeZero())
|
|
})
|
|
DescribeTable("rejects invalid inputs before requests", func(change func(*Command)) {
|
|
reached := false
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }))
|
|
defer server.Close()
|
|
cmd.Endpoint = server.URL
|
|
change(&cmd)
|
|
Expect(cmd.run(context.Background(), &output)).NotTo(Succeed())
|
|
Expect(reached).To(BeFalse())
|
|
Expect(output.Len()).To(BeZero())
|
|
}, Entry("runs", func(c *Command) { c.Runs = 0 }), Entry("warmup", func(c *Command) { c.Warmup = -1 }), Entry("tokens", func(c *Command) { c.MaxTokens = 0 }), Entry("timeout", func(c *Command) { c.Timeout = 0 }), Entry("prompt", func(c *Command) { c.Prompt = " " }), Entry("models", func(c *Command) { c.Models = nil }), Entry("blank model", func(c *Command) { c.Models = []string{"a", " "} }), Entry("scheme", func(c *Command) { c.Endpoint = "file:///tmp" }), Entry("host", func(c *Command) { c.Endpoint = "http:///v1" }), Entry("userinfo", func(c *Command) { c.Endpoint = "http://secret@localhost" }), Entry("query", func(c *Command) { c.Endpoint += "?secret" }), Entry("fragment", func(c *Command) { c.Endpoint += "#secret" }))
|
|
})
|