From 66ad319ccb97f4fcff0b2e99dd8fac223cce9e71 Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Fri, 29 May 2026 09:38:59 +0800 Subject: [PATCH] feat(routing): add sticky-backed route resolver --- internal/app/http_api.go | 6 + internal/app/route_resolve_api.go | 404 +++++++++++++++++++++++++ internal/app/route_resolve_api_test.go | 185 +++++++++++ 3 files changed, 595 insertions(+) create mode 100644 internal/app/route_resolve_api.go create mode 100644 internal/app/route_resolve_api_test.go diff --git a/internal/app/http_api.go b/internal/app/http_api.go index 2d042638..4e14098a 100644 --- a/internal/app/http_api.go +++ b/internal/app/http_api.go @@ -51,6 +51,7 @@ type ActionSet struct { ListRouteFailoverEvents func(context.Context, ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error) AppendRouteStickyAudit func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error) ListRouteStickyAudit func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error) + ResolveRoute func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error) SetStickyBinding func(context.Context, SetStickyBindingRequest) (StickyBindingInfo, error) GetStickyBinding func(context.Context, GetStickyBindingRequest) (StickyBindingInfo, error) SetRouteFailure func(context.Context, SetRouteFailureRequest) (RouteFailureInfo, error) @@ -398,6 +399,9 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha mux.Handle("GET /api/routing/logs/sticky-audit", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleListRouteStickyAudit(w, r, actions.ListRouteStickyAudit) }))) + mux.Handle("POST /api/routing/resolve", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handleResolveRoute(w, r, actions.ResolveRoute) + }))) mux.Handle("POST /api/routing/sticky/bindings", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handleSetStickyBinding(w, r, actions.SetStickyBinding) }))) @@ -1225,6 +1229,7 @@ func NewActionSet(sqliteDSN string) ActionSet { func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRuntime) ActionSet { routeLogWriter := newLazyRouteLogWriter(sqliteDSN) + resolveRoute := buildResolveRouteAction(sqliteDSN, stickyRuntime, routeLogWriter) return ActionSet{ CreateBatchImportRun: buildCreateBatchImportRunAction(sqliteDSN), ListBatchImportRuns: buildListBatchImportRunsAction(sqliteDSN), @@ -1251,6 +1256,7 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu ListRouteFailoverEvents: buildListRouteFailoverEventsAction(sqliteDSN), AppendRouteStickyAudit: buildAppendRouteStickyAuditAction(routeLogWriter, sqliteDSN), ListRouteStickyAudit: buildListRouteStickyAuditAction(sqliteDSN), + ResolveRoute: resolveRoute, SetStickyBinding: buildSetStickyBindingAction(stickyRuntime), GetStickyBinding: buildGetStickyBindingAction(stickyRuntime), SetRouteFailure: buildSetRouteFailureAction(stickyRuntime), diff --git a/internal/app/route_resolve_api.go b/internal/app/route_resolve_api.go new file mode 100644 index 00000000..83ab638a --- /dev/null +++ b/internal/app/route_resolve_api.go @@ -0,0 +1,404 @@ +package app + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "sub2api-cn-relay-manager/internal/routing" + "sub2api-cn-relay-manager/internal/store/sqlite" +) + +type ResolveRouteRequest struct { + RequestID string `json:"request_id,omitempty"` + LogicalGroupID string `json:"logical_group_id"` + PublicModel string `json:"public_model"` + Scope string `json:"scope"` + SubjectID string `json:"subject_id"` + UserKey string `json:"user_key,omitempty"` + ConversationKey string `json:"conversation_key,omitempty"` + Sync bool `json:"sync,omitempty"` +} + +type ResolveRouteInfo struct { + RequestID string `json:"request_id"` + Backend string `json:"backend"` + LogicalGroupID string `json:"logical_group_id"` + PublicModel string `json:"public_model"` + Scope string `json:"scope"` + SubjectID string `json:"subject_id"` + StickyKey string `json:"sticky_key"` + StickyHit bool `json:"sticky_hit"` + StickyAction string `json:"sticky_action"` + RouteID string `json:"route_id"` + RouteName string `json:"route_name,omitempty"` + ShadowGroupID string `json:"shadow_group_id"` + ShadowHostID string `json:"shadow_host_id"` + ShadowModel string `json:"shadow_model,omitempty"` + Priority int `json:"priority"` + Weight int `json:"weight"` + BoundAt string `json:"bound_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +type resolvedRouteCandidate struct { + route sqlite.LogicalGroupRoute + routeModel sqlite.LogicalGroupRouteModel +} + +func handleResolveRoute(w http.ResponseWriter, r *http.Request, fn func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error)) { + if fn == nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "resolve-route action is not configured"}) + return + } + var req ResolveRouteRequest + if err := decodeJSON(r, &req); err != nil { + writeHTTPError(w, err) + return + } + info, err := fn(r.Context(), req) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + writeJSON(w, http.StatusOK, map[string]any{"resolve": info}) +} + +func buildResolveRouteAction(sqliteDSN string, stickyRuntime stickyStoreRuntime, writerSource *lazyRouteLogWriter) func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error) { + return func(ctx context.Context, req ResolveRouteRequest) (ResolveRouteInfo, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return ResolveRouteInfo{}, err + } + defer store.Close() + + group, err := getLogicalGroupRow(ctx, store, req.LogicalGroupID) + if err != nil { + return ResolveRouteInfo{}, err + } + if !isActiveStatus(group.Status) { + return ResolveRouteInfo{}, fmt.Errorf("logical group %q is not active", group.LogicalGroupID) + } + + req, stickyKey, requestID, err := normalizeResolveRouteRequest(req) + if err != nil { + return ResolveRouteInfo{}, err + } + + writer, err := writerSource.get(ctx) + if err != nil { + return ResolveRouteInfo{}, err + } + + binding, stickyHit, err := lookupValidStickyBinding(ctx, store, stickyRuntime, stickyKey, req) + if err != nil { + return ResolveRouteInfo{}, err + } + + if stickyHit { + candidate, err := loadResolvedRouteCandidate(ctx, store, group.LogicalGroupID, req.PublicModel, binding.RouteID) + if err != nil { + if err := stickyRuntime.store.Delete(ctx, stickyKey); err != nil { + return ResolveRouteInfo{}, err + } + } else { + info := resolveRouteInfoFromBinding(stickyRuntime.backend, stickyKey, req.Scope, req.SubjectID, candidate, binding, requestID, true, "hit") + if err := writer.AppendStickyAudit(ctx, routing.RouteStickyAuditEvent{ + StickyKey: stickyKey, + StickyKeyType: req.Scope, + LogicalGroupID: req.LogicalGroupID, + PublicModel: req.PublicModel, + RouteID: candidate.route.RouteID, + Action: "hit", + ExpiresAt: binding.ExpiresAt, + }); err != nil { + return ResolveRouteInfo{}, err + } + if err := writer.AppendDecision(ctx, routing.RouteDecisionEvent{ + RequestID: requestID, + LogicalGroupID: req.LogicalGroupID, + PublicModel: req.PublicModel, + UserKey: resolveUserKey(req), + ConversationKey: resolveConversationKey(req), + StickyKey: stickyKey, + StickyKeyType: req.Scope, + StickyHit: true, + SelectedRouteID: candidate.route.RouteID, + SelectedShadowGroupID: candidate.route.ShadowGroupID, + }); err != nil { + return ResolveRouteInfo{}, err + } + if req.Sync { + if err := writer.Flush(ctx); err != nil { + return ResolveRouteInfo{}, err + } + } + return info, nil + } + } + + candidate, err := selectResolvedRouteCandidate(ctx, store, stickyRuntime, group, req.PublicModel) + if err != nil { + return ResolveRouteInfo{}, err + } + + ttl, err := resolveStickyTTL(group, req.Scope) + if err != nil { + return ResolveRouteInfo{}, err + } + binding = routing.StickyBinding{ + LogicalGroupID: req.LogicalGroupID, + PublicModel: req.PublicModel, + RouteID: candidate.route.RouteID, + ShadowGroupID: candidate.route.ShadowGroupID, + } + if err := stickyRuntime.store.Set(ctx, stickyKey, binding, ttl); err != nil { + return ResolveRouteInfo{}, err + } + stored, ok, err := stickyRuntime.store.Get(ctx, stickyKey) + if err != nil { + return ResolveRouteInfo{}, err + } + if !ok { + return ResolveRouteInfo{}, fmt.Errorf("sticky binding %q not found after set", stickyKey) + } + + if err := writer.AppendStickyAudit(ctx, routing.RouteStickyAuditEvent{ + StickyKey: stickyKey, + StickyKeyType: req.Scope, + LogicalGroupID: req.LogicalGroupID, + PublicModel: req.PublicModel, + RouteID: candidate.route.RouteID, + Action: "bind", + ExpiresAt: stored.ExpiresAt, + }); err != nil { + return ResolveRouteInfo{}, err + } + if err := writer.AppendDecision(ctx, routing.RouteDecisionEvent{ + RequestID: requestID, + LogicalGroupID: req.LogicalGroupID, + PublicModel: req.PublicModel, + UserKey: resolveUserKey(req), + ConversationKey: resolveConversationKey(req), + StickyKey: stickyKey, + StickyKeyType: req.Scope, + StickyHit: false, + SelectedRouteID: candidate.route.RouteID, + SelectedShadowGroupID: candidate.route.ShadowGroupID, + }); err != nil { + return ResolveRouteInfo{}, err + } + if req.Sync { + if err := writer.Flush(ctx); err != nil { + return ResolveRouteInfo{}, err + } + } + return resolveRouteInfoFromBinding(stickyRuntime.backend, stickyKey, req.Scope, req.SubjectID, candidate, stored, requestID, false, "bind"), nil + } +} + +func normalizeResolveRouteRequest(req ResolveRouteRequest) (ResolveRouteRequest, string, string, error) { + req.RequestID = strings.TrimSpace(req.RequestID) + req.LogicalGroupID = strings.TrimSpace(req.LogicalGroupID) + req.PublicModel = strings.TrimSpace(req.PublicModel) + req.Scope = strings.TrimSpace(req.Scope) + req.SubjectID = strings.TrimSpace(req.SubjectID) + req.UserKey = strings.TrimSpace(req.UserKey) + req.ConversationKey = strings.TrimSpace(req.ConversationKey) + + if req.LogicalGroupID == "" { + return ResolveRouteRequest{}, "", "", fmt.Errorf("logical_group_id is required") + } + if req.PublicModel == "" { + return ResolveRouteRequest{}, "", "", fmt.Errorf("public_model is required") + } + if req.Scope == "" { + return ResolveRouteRequest{}, "", "", fmt.Errorf("scope is required") + } + if req.SubjectID == "" { + return ResolveRouteRequest{}, "", "", fmt.Errorf("subject_id is required") + } + + stickyKey, err := routing.BuildStickyKey(req.Scope, req.LogicalGroupID, req.PublicModel, req.SubjectID) + if err != nil { + return ResolveRouteRequest{}, "", "", err + } + requestID := req.RequestID + if requestID == "" { + requestID = fmt.Sprintf("resolve_%d", time.Now().UnixNano()) + } + return req, stickyKey, requestID, nil +} + +func lookupValidStickyBinding(ctx context.Context, store *sqlite.DB, runtime stickyStoreRuntime, stickyKey string, req ResolveRouteRequest) (routing.StickyBinding, bool, error) { + binding, ok, err := runtime.store.Get(ctx, stickyKey) + if err != nil || !ok { + return routing.StickyBinding{}, false, err + } + candidate, err := loadResolvedRouteCandidate(ctx, store, req.LogicalGroupID, req.PublicModel, binding.RouteID) + if err != nil { + if deleteErr := runtime.store.Delete(ctx, stickyKey); deleteErr != nil { + return routing.StickyBinding{}, false, deleteErr + } + return routing.StickyBinding{}, false, nil + } + cooldown, ok, err := runtime.store.GetCooldown(ctx, candidate.route.RouteID) + if err != nil { + return routing.StickyBinding{}, false, err + } + if ok && !isExpiredRFC3339(cooldown.Until) { + if deleteErr := runtime.store.Delete(ctx, stickyKey); deleteErr != nil { + return routing.StickyBinding{}, false, deleteErr + } + return routing.StickyBinding{}, false, nil + } + return binding, true, nil +} + +func selectResolvedRouteCandidate(ctx context.Context, store *sqlite.DB, runtime stickyStoreRuntime, group sqlite.LogicalGroup, publicModel string) (resolvedRouteCandidate, error) { + models, err := store.LogicalGroupModels().ListByLogicalGroupID(ctx, group.LogicalGroupID) + if err != nil { + return resolvedRouteCandidate{}, err + } + if !logicalGroupHasActiveModel(models, publicModel) { + return resolvedRouteCandidate{}, fmt.Errorf("logical group %q does not expose active model %q", group.LogicalGroupID, publicModel) + } + + routes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, group.LogicalGroupID) + if err != nil { + return resolvedRouteCandidate{}, err + } + for _, route := range routes { + if !isActiveStatus(route.Status) { + continue + } + if !isExpiredRFC3339(route.CooldownUntil) { + continue + } + cooldown, ok, err := runtime.store.GetCooldown(ctx, route.RouteID) + if err != nil { + return resolvedRouteCandidate{}, err + } + if ok && !isExpiredRFC3339(cooldown.Until) { + continue + } + routeModels, err := store.LogicalGroupRouteModels().ListByRouteID(ctx, route.RouteID) + if err != nil { + return resolvedRouteCandidate{}, err + } + for _, routeModel := range routeModels { + if strings.TrimSpace(routeModel.PublicModel) == publicModel && isActiveStatus(routeModel.Status) { + return resolvedRouteCandidate{route: route, routeModel: routeModel}, nil + } + } + } + return resolvedRouteCandidate{}, fmt.Errorf("no active route found for logical group %q model %q", group.LogicalGroupID, publicModel) +} + +func loadResolvedRouteCandidate(ctx context.Context, store *sqlite.DB, logicalGroupID string, publicModel string, routeID string) (resolvedRouteCandidate, error) { + route, err := getLogicalGroupRouteRow(ctx, store, routeID) + if err != nil { + return resolvedRouteCandidate{}, err + } + if route.LogicalGroupID != logicalGroupID { + return resolvedRouteCandidate{}, fmt.Errorf("logical group route %q not found under logical group %q", route.RouteID, logicalGroupID) + } + if !isActiveStatus(route.Status) { + return resolvedRouteCandidate{}, fmt.Errorf("logical group route %q is not active", route.RouteID) + } + if !isExpiredRFC3339(route.CooldownUntil) { + return resolvedRouteCandidate{}, fmt.Errorf("logical group route %q is cooling down", route.RouteID) + } + routeModels, err := store.LogicalGroupRouteModels().ListByRouteID(ctx, route.RouteID) + if err != nil { + return resolvedRouteCandidate{}, err + } + for _, routeModel := range routeModels { + if strings.TrimSpace(routeModel.PublicModel) == strings.TrimSpace(publicModel) && isActiveStatus(routeModel.Status) { + return resolvedRouteCandidate{route: route, routeModel: routeModel}, nil + } + } + return resolvedRouteCandidate{}, fmt.Errorf("logical group route %q does not expose active model %q", route.RouteID, publicModel) +} + +func logicalGroupHasActiveModel(models []sqlite.LogicalGroupModel, publicModel string) bool { + publicModel = strings.TrimSpace(publicModel) + for _, model := range models { + if strings.TrimSpace(model.PublicModel) == publicModel && isActiveStatus(model.Status) { + return true + } + } + return false +} + +func resolveStickyTTL(group sqlite.LogicalGroup, scope string) (time.Duration, error) { + switch strings.ToLower(strings.TrimSpace(scope)) { + case routing.StickyScopeConversation, routing.StickyScopeSession: + return secondsToDuration(group.ConversationTTLSeconds, 0) + case routing.StickyScopeUser: + return secondsToDuration(group.UserModelTTLSeconds, 0) + default: + return 0, fmt.Errorf("unsupported sticky scope %q", scope) + } +} + +func resolveRouteInfoFromBinding(backend string, stickyKey string, scope string, subjectID string, candidate resolvedRouteCandidate, binding routing.StickyBinding, requestID string, stickyHit bool, stickyAction string) ResolveRouteInfo { + return ResolveRouteInfo{ + RequestID: requestID, + Backend: backend, + LogicalGroupID: binding.LogicalGroupID, + PublicModel: binding.PublicModel, + Scope: strings.TrimSpace(scope), + SubjectID: strings.TrimSpace(subjectID), + StickyKey: stickyKey, + StickyHit: stickyHit, + StickyAction: stickyAction, + RouteID: candidate.route.RouteID, + RouteName: candidate.route.Name, + ShadowGroupID: candidate.route.ShadowGroupID, + ShadowHostID: candidate.route.ShadowHostID, + ShadowModel: candidate.routeModel.ShadowModel, + Priority: candidate.route.Priority, + Weight: candidate.route.Weight, + BoundAt: binding.BoundAt, + ExpiresAt: binding.ExpiresAt, + } +} + +func resolveUserKey(req ResolveRouteRequest) string { + if req.UserKey != "" { + return req.UserKey + } + if strings.EqualFold(req.Scope, routing.StickyScopeUser) { + return req.SubjectID + } + return "" +} + +func resolveConversationKey(req ResolveRouteRequest) string { + if req.ConversationKey != "" { + return req.ConversationKey + } + if strings.EqualFold(req.Scope, routing.StickyScopeConversation) || strings.EqualFold(req.Scope, routing.StickyScopeSession) { + return req.SubjectID + } + return "" +} + +func isActiveStatus(status string) bool { + return strings.EqualFold(strings.TrimSpace(status), "active") +} + +func isExpiredRFC3339(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return true + } + until, err := time.Parse(time.RFC3339, raw) + if err != nil { + return true + } + return !until.After(time.Now().UTC()) +} diff --git a/internal/app/route_resolve_api_test.go b/internal/app/route_resolve_api_test.go new file mode 100644 index 00000000..7e25f7ac --- /dev/null +++ b/internal/app/route_resolve_api_test.go @@ -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) + } +}