feat(routing): add sticky-backed route resolver
This commit is contained in:
185
internal/app/route_resolve_api_test.go
Normal file
185
internal/app/route_resolve_api_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPIResolveRouteReturnsSelectedRoute(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ResolveRoute: func(_ context.Context, req ResolveRouteRequest) (ResolveRouteInfo, error) {
|
||||
if req.LogicalGroupID != "gpt-shared" {
|
||||
t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID)
|
||||
}
|
||||
return ResolveRouteInfo{
|
||||
RequestID: "req-1",
|
||||
Backend: "memory",
|
||||
LogicalGroupID: req.LogicalGroupID,
|
||||
PublicModel: req.PublicModel,
|
||||
StickyKey: "lg:gpt-shared:m:gpt-5.4:conv:conv-1",
|
||||
StickyHit: false,
|
||||
StickyAction: "bind",
|
||||
RouteID: "asxs",
|
||||
ShadowGroupID: "gpt-shared__asxs",
|
||||
ShadowHostID: "remote43",
|
||||
ShadowModel: "gpt-5.4",
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, http.MethodPost, "/api/routing/resolve", map[string]any{
|
||||
"logical_group_id": "gpt-shared",
|
||||
"public_model": "gpt-5.4",
|
||||
"scope": "conversation",
|
||||
"subject_id": "conv-1",
|
||||
"sync": true,
|
||||
}, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
assertJSONContains(t, response.Body().Bytes(), "resolve.route_id", "asxs")
|
||||
assertJSONContains(t, response.Body().Bytes(), "resolve.sticky_action", "bind")
|
||||
}
|
||||
|
||||
func TestNewActionSetResolveRouteFlow(t *testing.T) {
|
||||
dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "route-resolve.db")) + "?_busy_timeout=5000"
|
||||
actions := NewActionSet(dsn)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := actions.CreateLogicalGroup(ctx, CreateLogicalGroupRequest{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
DisplayName: "GPT Shared",
|
||||
Status: "active",
|
||||
RoutePolicy: "priority",
|
||||
StickyMode: "conversation_preferred",
|
||||
ConversationTTLSeconds: 1200,
|
||||
UserModelTTLSeconds: 600,
|
||||
FailoverThreshold: 2,
|
||||
CooldownSeconds: 300,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLogicalGroup() error = %v", err)
|
||||
}
|
||||
if _, err := actions.CreateLogicalGroupModel(ctx, CreateLogicalGroupModelRequest{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
PublicModel: "gpt-5.4",
|
||||
Status: "active",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateLogicalGroupModel() error = %v", err)
|
||||
}
|
||||
if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
RouteID: "asxs",
|
||||
Name: "ASXS",
|
||||
Status: "active",
|
||||
Priority: 20,
|
||||
ShadowGroupID: "gpt-shared__asxs",
|
||||
ShadowHostID: "remote43",
|
||||
UpstreamBaseURLHint: "https://api.asxs.top/v1",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateLogicalGroupRoute(asxs) error = %v", err)
|
||||
}
|
||||
if _, err := actions.CreateLogicalGroupRouteModel(ctx, CreateLogicalGroupRouteModelRequest{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
RouteID: "asxs",
|
||||
PublicModel: "gpt-5.4",
|
||||
ShadowModel: "gpt-5.4-asxs",
|
||||
Status: "active",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateLogicalGroupRouteModel(asxs) error = %v", err)
|
||||
}
|
||||
if _, err := actions.CreateLogicalGroupRoute(ctx, CreateLogicalGroupRouteRequest{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
RouteID: "codex2api",
|
||||
Name: "Codex2API",
|
||||
Status: "active",
|
||||
Priority: 10,
|
||||
ShadowGroupID: "gpt-shared__codex2api",
|
||||
ShadowHostID: "remote43",
|
||||
UpstreamBaseURLHint: "https://www.codex2api.com/v1",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateLogicalGroupRoute(codex2api) error = %v", err)
|
||||
}
|
||||
if _, err := actions.CreateLogicalGroupRouteModel(ctx, CreateLogicalGroupRouteModelRequest{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
RouteID: "codex2api",
|
||||
PublicModel: "gpt-5.4",
|
||||
ShadowModel: "gpt-5.4",
|
||||
Status: "active",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateLogicalGroupRouteModel(codex2api) error = %v", err)
|
||||
}
|
||||
|
||||
first, err := actions.ResolveRoute(ctx, ResolveRouteRequest{
|
||||
RequestID: "req-resolve-1",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
PublicModel: "gpt-5.4",
|
||||
Scope: "conversation",
|
||||
SubjectID: "conv-1",
|
||||
Sync: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRoute(first) error = %v", err)
|
||||
}
|
||||
if first.RouteID != "codex2api" || first.StickyHit || first.StickyAction != "bind" {
|
||||
t.Fatalf("ResolveRoute(first) = %+v, want codex2api bind miss", first)
|
||||
}
|
||||
|
||||
second, err := actions.ResolveRoute(ctx, ResolveRouteRequest{
|
||||
RequestID: "req-resolve-2",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
PublicModel: "gpt-5.4",
|
||||
Scope: "conversation",
|
||||
SubjectID: "conv-1",
|
||||
Sync: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRoute(second) error = %v", err)
|
||||
}
|
||||
if second.RouteID != "codex2api" || !second.StickyHit || second.StickyAction != "hit" {
|
||||
t.Fatalf("ResolveRoute(second) = %+v, want codex2api sticky hit", second)
|
||||
}
|
||||
|
||||
sticky, err := actions.GetStickyBinding(ctx, GetStickyBindingRequest{
|
||||
Scope: "conversation",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
PublicModel: "gpt-5.4",
|
||||
SubjectID: "conv-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetStickyBinding() error = %v", err)
|
||||
}
|
||||
if sticky.RouteID != "codex2api" {
|
||||
t.Fatalf("GetStickyBinding() = %+v, want route codex2api", sticky)
|
||||
}
|
||||
|
||||
decisions, err := actions.ListRouteDecisionLogs(ctx, ListRouteDecisionLogsRequest{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
StickyKey: first.StickyKey,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRouteDecisionLogs() error = %v", err)
|
||||
}
|
||||
if len(decisions) != 2 {
|
||||
t.Fatalf("ListRouteDecisionLogs() len = %d, want 2", len(decisions))
|
||||
}
|
||||
if !decisions[0].StickyHit || decisions[1].StickyHit {
|
||||
t.Fatalf("ListRouteDecisionLogs() = %+v, want latest hit then miss", decisions)
|
||||
}
|
||||
|
||||
stickyAudits, err := actions.ListRouteStickyAudit(ctx, ListRouteStickyAuditRequest{
|
||||
StickyKey: first.StickyKey,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRouteStickyAudit() error = %v", err)
|
||||
}
|
||||
if len(stickyAudits) != 2 {
|
||||
t.Fatalf("ListRouteStickyAudit() len = %d, want 2", len(stickyAudits))
|
||||
}
|
||||
if stickyAudits[0].Action != "hit" || stickyAudits[1].Action != "bind" {
|
||||
t.Fatalf("ListRouteStickyAudit() = %+v, want latest hit then bind", stickyAudits)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user