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
294 lines
6.7 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|