Charts, totals and budgets summed the request table on every view. Usage is now also summed in the writing transaction into five-minute, hourly and daily tables per key, person, project and model, with latency and first-token histograms. Charts read the coarsest tier their slices allow; totals and budgets compose rows, slices, hours and days, and stay exact. Request records can be given a retention without moving any total. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
223 lines
6.4 KiB
Go
223 lines
6.4 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
)
|
|
|
|
func write(t *testing.T, content string) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "server.yaml")
|
|
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func TestLoadDefaultsWithoutFile(t *testing.T) {
|
|
cfg, err := Load("")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Listen != ":8080" || cfg.Router.DefaultProfile != "chat" {
|
|
t.Errorf("defaults not applied: %+v", cfg)
|
|
}
|
|
}
|
|
|
|
func TestLoadOverlaysOnDefaults(t *testing.T) {
|
|
// Specifying one field must not blank out its neighbours.
|
|
p := write(t, `
|
|
listen: ":9999"
|
|
endpoints:
|
|
- id: a
|
|
url: http://127.0.0.1:8000
|
|
models: [m]
|
|
`)
|
|
cfg, err := Load(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Listen != ":9999" {
|
|
t.Errorf("listen=%q", cfg.Listen)
|
|
}
|
|
if cfg.Router.ScrapeInterval != 2*time.Second {
|
|
t.Errorf("an unspecified default was lost: %v", cfg.Router.ScrapeInterval)
|
|
}
|
|
if len(cfg.Endpoints) != 1 || cfg.Endpoints[0].ID != "a" {
|
|
t.Errorf("endpoints=%+v", cfg.Endpoints)
|
|
}
|
|
}
|
|
|
|
func TestDurationsParse(t *testing.T) {
|
|
p := write(t, `
|
|
router:
|
|
scrape_interval: 500ms
|
|
timeouts:
|
|
idle: 45s
|
|
`)
|
|
cfg, err := Load(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Router.ScrapeInterval != 500*time.Millisecond {
|
|
t.Errorf("scrape_interval=%v", cfg.Router.ScrapeInterval)
|
|
}
|
|
if cfg.Router.Timeouts.Idle != 45*time.Second {
|
|
t.Errorf("timeouts.idle=%v", cfg.Router.Timeouts.Idle)
|
|
}
|
|
}
|
|
|
|
func TestEnvOverride(t *testing.T) {
|
|
t.Setenv("LLMGW_ROUTER__RETRIES", "7")
|
|
cfg, err := Load("")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Router.Retries != 7 {
|
|
t.Errorf("retries=%d, expected the environment to win", cfg.Router.Retries)
|
|
}
|
|
}
|
|
|
|
func TestMissingFileIsAnError(t *testing.T) {
|
|
if _, err := Load("/nonexistent/server.yaml"); err == nil {
|
|
t.Error("expected an error for a missing config file")
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsBadEndpoints(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
ep Endpoint
|
|
want string
|
|
}{
|
|
{"no id", Endpoint{URL: "http://x", Models: []string{"m"}}, "id is required"},
|
|
{"no url", Endpoint{ID: "a", Models: []string{"m"}}, "url is required"},
|
|
{"bad scheme", Endpoint{ID: "a", URL: "127.0.0.1:8000", Models: []string{"m"}}, "http://"},
|
|
{"no models", Endpoint{ID: "a", URL: "http://x"}, "at least one model"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
s := Default()
|
|
s.Endpoints = []Endpoint{c.ep}
|
|
err := s.Validate()
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
if !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("error %q does not mention %q", err, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsDuplicateEndpointID(t *testing.T) {
|
|
s := Default()
|
|
s.Endpoints = []Endpoint{
|
|
{ID: "a", URL: "http://x", Models: []string{"m"}},
|
|
{ID: "a", URL: "http://y", Models: []string{"m"}},
|
|
}
|
|
if err := s.Validate(); err == nil || !strings.Contains(err.Error(), "twice") {
|
|
t.Errorf("duplicate id not rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsAliasCollision(t *testing.T) {
|
|
// An alias shadowing another model would silently misroute every request
|
|
// for that model, so it has to fail at load time.
|
|
s := Default()
|
|
s.Models = []catalog.Model{
|
|
{Name: "llama-3.3-70b", Source: "/m", Aliases: []string{"qwen"},
|
|
Profiles: []catalog.Profile{{Name: "p", GPUClass: "h100-80g", TP: 1}}},
|
|
{Name: "qwen", Source: "/m",
|
|
Profiles: []catalog.Profile{{Name: "p", GPUClass: "h100-80g", TP: 1}}},
|
|
}
|
|
err := s.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "collides") {
|
|
t.Errorf("alias collision not rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateAcceptsDistinctAliases(t *testing.T) {
|
|
s := Default()
|
|
s.Models = []catalog.Model{
|
|
{Name: "llama-3.3-70b", Source: "/m", Aliases: []string{"llama", "llama3"},
|
|
Profiles: []catalog.Profile{{Name: "p", GPUClass: "h100-80g", TP: 1}}},
|
|
{Name: "qwen2.5-32b", Source: "/m", Aliases: []string{"qwen"},
|
|
Profiles: []catalog.Profile{{Name: "p", GPUClass: "h100-80g", TP: 1}}},
|
|
}
|
|
if err := s.Validate(); err != nil {
|
|
t.Errorf("valid aliases rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnEmptyConfigurationIsAControlPlane(t *testing.T) {
|
|
// A new cluster has no models and no endpoints. Refusing it as having
|
|
// nothing to serve left a systemd unit restarting every two seconds and
|
|
// nothing listening on 8080 — on the first start anyone ever made.
|
|
var s Server
|
|
if !s.ControlPlaneMode() {
|
|
t.Fatal("a configuration with no endpoints and no models should run a control plane")
|
|
}
|
|
}
|
|
|
|
func TestStaticEndpointsAloneAreRouterOnly(t *testing.T) {
|
|
var s Server
|
|
s.Endpoints = []Endpoint{{ID: "e", URL: "http://127.0.0.1:1"}}
|
|
s.Models = []catalog.Model{{Name: "m"}}
|
|
if s.ControlPlaneMode() {
|
|
t.Error("static endpoints and bare model names are the router-only mode")
|
|
}
|
|
s.Control.Enabled = true
|
|
if !s.ControlPlaneMode() {
|
|
t.Error("control.enabled should run the control plane alongside static endpoints")
|
|
}
|
|
}
|
|
|
|
func TestAModelWithProfilesStillMakesAControlPlane(t *testing.T) {
|
|
// The inference stays, so no existing configuration has to add a flag.
|
|
var s Server
|
|
s.Models = []catalog.Model{{
|
|
Name: "m",
|
|
Profiles: []catalog.Profile{{Name: "p", Engine: "llamacpp"}},
|
|
}}
|
|
if !s.ControlPlaneMode() {
|
|
t.Error("a declared model with profiles is still a control plane")
|
|
}
|
|
}
|
|
|
|
func TestEnginesDefaultToTheCheckedVersions(t *testing.T) {
|
|
var s Server
|
|
if got := s.WantedEngines()["llamacpp"]; got != DefaultEngines["llamacpp"] {
|
|
t.Errorf("unset config wants llamacpp %q", got)
|
|
}
|
|
s.Engines = map[string]string{"llamacpp": "b11000", "vllm": "0.29.0"}
|
|
got := s.WantedEngines()
|
|
if got["llamacpp"] != "b11000" || got["vllm"] != "0.29.0" {
|
|
t.Errorf("configured versions not taken: %v", got)
|
|
}
|
|
// Empty leaves an engine to be installed by hand.
|
|
s.Engines = map[string]string{"llamacpp": ""}
|
|
if _, ok := s.WantedEngines()["llamacpp"]; ok {
|
|
t.Error("an emptied engine is still wanted")
|
|
}
|
|
}
|
|
|
|
func TestAUsageRetentionUnderADayIsRefused(t *testing.T) {
|
|
// Shorter than a day is almost certainly a unit slip — 90m for 90 days —
|
|
// and would delete every request record within the hour.
|
|
s := Default()
|
|
s.Auth.UsageRetention = 90 * time.Minute
|
|
if err := s.Validate(); err == nil || !strings.Contains(err.Error(), "usage_retention") {
|
|
t.Errorf("a 90-minute retention was accepted: %v", err)
|
|
}
|
|
s.Auth.UsageRetention = 90 * 24 * time.Hour
|
|
if err := s.Validate(); err != nil {
|
|
t.Errorf("a 90-day retention was refused: %v", err)
|
|
}
|
|
}
|