ai-servers/llm-gateway/internal/provider/balancer_test.go
Ray Andrew 90adf6f3a8
feat(gateway): add circuit breaker, retry, and concurrency limit support
feat(gateway): add debug logging with file storage and retention

feat(gateway): add audit logging for user actions

feat(gateway): add request ID tracking and rate limit headers

feat(gateway): add model aliases and load balancing strategies

feat(gateway): add config hot-reload via SIGHUP

feat(gateway): add CORS support

feat(gateway): add data export API and dashboard endpoints

feat(gateway): add dashboard pages for audit and debug logs

feat(gateway): add concurrent request limiting per token

feat(gateway): add streaming timeout support

feat(gateway): add migration support for new schema fields
2026-02-15 04:21:40 -06:00

294 lines
6.7 KiB
Go

package provider
import (
"fmt"
"testing"
)
type routeSpec struct {
name string
priority int
input float64
output float64
}
func makeRoutes(specs ...routeSpec) []Route {
routes := make([]Route, len(specs))
for i, s := range specs {
routes[i] = Route{
Provider: &mockProvider{name: s.name},
ProviderModel: s.name + "-model",
Priority: s.priority,
InputPrice: s.input,
OutputPrice: s.output,
}
}
return routes
}
func routeNames(routes []Route) []string {
names := make([]string, len(routes))
for i, r := range routes {
names[i] = r.Provider.Name()
}
return names
}
func TestFirstBalancer_PreservesOrder(t *testing.T) {
routes := makeRoutes(
routeSpec{"a", 1, 1.0, 1.0},
routeSpec{"b", 1, 2.0, 2.0},
routeSpec{"c", 1, 3.0, 3.0},
)
b := &FirstBalancer{}
result := b.Reorder(routes)
names := routeNames(result)
if names[0] != "a" || names[1] != "b" || names[2] != "c" {
t.Fatalf("expected [a b c], got %v", names)
}
}
func TestRoundRobinBalancer_RotatesWithinPriorityGroup(t *testing.T) {
routes := makeRoutes(
routeSpec{"a", 1, 1.0, 1.0},
routeSpec{"b", 1, 1.0, 1.0},
routeSpec{"c", 1, 1.0, 1.0},
)
b := &RoundRobinBalancer{}
// Collect the first element from multiple calls
seen := make(map[string]bool)
for i := 0; i < 6; i++ {
result := b.Reorder(routes)
seen[result[0].Provider.Name()] = true
}
// All routes should have appeared as first at some point
for _, name := range []string{"a", "b", "c"} {
if !seen[name] {
t.Errorf("expected %q to appear as first element in rotation", name)
}
}
}
func TestRoundRobinBalancer_PreservesPriorityOrder(t *testing.T) {
routes := makeRoutes(
routeSpec{"a", 1, 1.0, 1.0},
routeSpec{"b", 1, 1.0, 1.0},
routeSpec{"c", 2, 1.0, 1.0},
)
b := &RoundRobinBalancer{}
// Priority 2 route should always be last
for i := 0; i < 5; i++ {
result := b.Reorder(routes)
if result[2].Provider.Name() != "c" {
t.Fatalf("expected priority-2 route 'c' at the end, got %q", result[2].Provider.Name())
}
}
}
func TestRandomBalancer_AllRoutesPresent(t *testing.T) {
routes := makeRoutes(
routeSpec{"a", 1, 1.0, 1.0},
routeSpec{"b", 1, 1.0, 1.0},
routeSpec{"c", 1, 1.0, 1.0},
)
b := &RandomBalancer{}
for i := 0; i < 10; i++ {
result := b.Reorder(routes)
if len(result) != 3 {
t.Fatalf("expected 3 routes, got %d", len(result))
}
names := make(map[string]bool)
for _, r := range result {
names[r.Provider.Name()] = true
}
for _, want := range []string{"a", "b", "c"} {
if !names[want] {
t.Errorf("missing route %q in result", want)
}
}
}
}
func TestRandomBalancer_PreservesPriorityOrder(t *testing.T) {
routes := makeRoutes(
routeSpec{"a", 1, 1.0, 1.0},
routeSpec{"b", 1, 1.0, 1.0},
routeSpec{"c", 2, 1.0, 1.0},
)
b := &RandomBalancer{}
for i := 0; i < 10; i++ {
result := b.Reorder(routes)
if result[2].Provider.Name() != "c" {
t.Fatalf("expected priority-2 route 'c' last, got %q", result[2].Provider.Name())
}
}
}
func TestLeastCostBalancer_SortsByCost(t *testing.T) {
routes := makeRoutes(
routeSpec{"expensive", 1, 10.0, 10.0},
routeSpec{"cheap", 1, 1.0, 1.0},
routeSpec{"medium", 1, 5.0, 5.0},
)
b := &LeastCostBalancer{}
result := b.Reorder(routes)
names := routeNames(result)
expected := []string{"cheap", "medium", "expensive"}
for i, want := range expected {
if names[i] != want {
t.Errorf("position %d: got %q, want %q", i, names[i], want)
}
}
}
func TestLeastCostBalancer_PreservesPriorityOrder(t *testing.T) {
routes := makeRoutes(
routeSpec{"expensive-p1", 1, 10.0, 10.0},
routeSpec{"cheap-p1", 1, 1.0, 1.0},
routeSpec{"cheap-p2", 2, 0.5, 0.5},
)
b := &LeastCostBalancer{}
result := b.Reorder(routes)
names := routeNames(result)
// Within priority 1, cheap should come first; priority 2 always last
if names[0] != "cheap-p1" {
t.Errorf("expected cheap-p1 first, got %q", names[0])
}
if names[1] != "expensive-p1" {
t.Errorf("expected expensive-p1 second, got %q", names[1])
}
if names[2] != "cheap-p2" {
t.Errorf("expected cheap-p2 last, got %q", names[2])
}
}
func TestGroupByPriority(t *testing.T) {
tests := []struct {
name string
priorities []int
wantGroups [][]int
}{
{
name: "empty",
priorities: nil,
wantGroups: nil,
},
{
name: "single",
priorities: []int{1},
wantGroups: [][]int{{1}},
},
{
name: "all same",
priorities: []int{1, 1, 1},
wantGroups: [][]int{{1, 1, 1}},
},
{
name: "two groups",
priorities: []int{1, 1, 2, 2},
wantGroups: [][]int{{1, 1}, {2, 2}},
},
{
name: "three groups",
priorities: []int{1, 2, 2, 3},
wantGroups: [][]int{{1}, {2, 2}, {3}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var routes []Route
for _, p := range tt.priorities {
routes = append(routes, Route{Priority: p})
}
groups := groupByPriority(routes)
if tt.wantGroups == nil {
if groups != nil {
t.Fatalf("expected nil groups, got %v", groups)
}
return
}
if len(groups) != len(tt.wantGroups) {
t.Fatalf("expected %d groups, got %d", len(tt.wantGroups), len(groups))
}
for i, wg := range tt.wantGroups {
if len(groups[i]) != len(wg) {
t.Errorf("group %d: expected %d routes, got %d", i, len(wg), len(groups[i]))
continue
}
for j, wp := range wg {
if groups[i][j].Priority != wp {
t.Errorf("group %d, route %d: expected priority %d, got %d", i, j, wp, groups[i][j].Priority)
}
}
}
})
}
}
func TestBalancer_SingleRoute(t *testing.T) {
routes := makeRoutes(routeSpec{"only", 1, 1.0, 1.0})
balancers := []struct {
name string
balancer LoadBalancer
}{
{"first", &FirstBalancer{}},
{"round-robin", &RoundRobinBalancer{}},
{"random", &RandomBalancer{}},
{"least-cost", &LeastCostBalancer{}},
}
for _, bb := range balancers {
t.Run(bb.name, func(t *testing.T) {
result := bb.balancer.Reorder(routes)
if len(result) != 1 || result[0].Provider.Name() != "only" {
t.Fatalf("expected single route 'only', got %v", routeNames(result))
}
})
}
}
func TestNewLoadBalancer(t *testing.T) {
tests := []struct {
strategy string
wantType string
}{
{"round-robin", "*provider.RoundRobinBalancer"},
{"random", "*provider.RandomBalancer"},
{"least-cost", "*provider.LeastCostBalancer"},
{"first", "*provider.FirstBalancer"},
{"unknown", "*provider.FirstBalancer"},
{"", "*provider.FirstBalancer"},
}
for _, tt := range tests {
t.Run(tt.strategy, func(t *testing.T) {
b := NewLoadBalancer(tt.strategy)
got := fmt.Sprintf("%T", b)
if got != tt.wantType {
t.Errorf("NewLoadBalancer(%q) = %s, want %s", tt.strategy, got, tt.wantType)
}
})
}
}