feat(routing): add route log writer and admin api

This commit is contained in:
phamnazage-jpg
2026-05-28 21:24:05 +08:00
parent 8097750ec0
commit 6e0bd59e56
14 changed files with 2139 additions and 0 deletions

View File

@@ -27,6 +27,9 @@ type Queries struct {
LogicalGroupModels *LogicalGroupModelsRepo
LogicalGroupRoutes *LogicalGroupRoutesRepo
LogicalGroupRouteModels *LogicalGroupRouteModelsRepo
RouteDecisionLogs *RouteDecisionLogsRepo
RouteFailoverEvents *RouteFailoverEventsRepo
RouteStickyAudit *RouteStickyAuditRepo
ProviderDrafts *ProviderDraftsRepo
ImportBatches *ImportBatchesRepo
ImportBatchItems *ImportBatchItemsRepo
@@ -112,6 +115,18 @@ func (db *DB) LogicalGroupRouteModels() *LogicalGroupRouteModelsRepo {
return db.queries.LogicalGroupRouteModels
}
func (db *DB) RouteDecisionLogs() *RouteDecisionLogsRepo {
return db.queries.RouteDecisionLogs
}
func (db *DB) RouteFailoverEvents() *RouteFailoverEventsRepo {
return db.queries.RouteFailoverEvents
}
func (db *DB) RouteStickyAudit() *RouteStickyAuditRepo {
return db.queries.RouteStickyAudit
}
func (db *DB) ProviderDrafts() *ProviderDraftsRepo {
return db.queries.ProviderDrafts
}
@@ -188,6 +203,9 @@ func newQueries(db execQuerier) *Queries {
LogicalGroupModels: newLogicalGroupModelsRepo(db),
LogicalGroupRoutes: newLogicalGroupRoutesRepo(db),
LogicalGroupRouteModels: newLogicalGroupRouteModelsRepo(db),
RouteDecisionLogs: newRouteDecisionLogsRepo(db),
RouteFailoverEvents: newRouteFailoverEventsRepo(db),
RouteStickyAudit: newRouteStickyAuditRepo(db),
ProviderDrafts: newProviderDraftsRepo(db),
ImportBatches: newImportBatchesRepo(db),
ImportBatchItems: newImportBatchItemsRepo(db),

View File

@@ -111,6 +111,9 @@ func TestOpenAppliesLogicalRoutingTables(t *testing.T) {
"logical_group_models",
"logical_group_routes",
"logical_group_route_models",
"route_decision_logs",
"route_failover_events",
"route_sticky_audit",
} {
found, err := tableExists(context.Background(), db, table)
if err != nil {

View File

@@ -0,0 +1,208 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type RouteDecisionLog struct {
ID int64
RequestID string
LogicalGroupID string
PublicModel string
UserKey string
ConversationKey string
StickyKey string
StickyKeyType string
StickyHit bool
SelectedRouteID string
SelectedShadowGroupID string
FallbackUsed bool
ErrorClass string
UpstreamStatus int
LatencyMS int
CreatedAt string
}
type RouteDecisionLogFilter struct {
RequestID string
LogicalGroupID string
PublicModel string
SelectedRouteID string
StickyKey string
Limit int
}
type RouteDecisionLogsRepo struct {
db execQuerier
}
func newRouteDecisionLogsRepo(db execQuerier) *RouteDecisionLogsRepo {
return &RouteDecisionLogsRepo{db: db}
}
func (r *RouteDecisionLogsRepo) Create(ctx context.Context, row RouteDecisionLog) (int64, error) {
row, err := normalizeRouteDecisionLog(row)
if err != nil {
return 0, err
}
result, err := r.db.ExecContext(
ctx,
`INSERT INTO route_decision_logs (
request_id,
logical_group_id,
public_model,
user_key,
conversation_key,
sticky_key,
sticky_key_type,
sticky_hit,
selected_route_id,
selected_shadow_group_id,
fallback_used,
error_class,
upstream_status,
latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
row.RequestID,
row.LogicalGroupID,
row.PublicModel,
row.UserKey,
row.ConversationKey,
row.StickyKey,
row.StickyKeyType,
boolToSQLiteInt(row.StickyHit),
row.SelectedRouteID,
row.SelectedShadowGroupID,
boolToSQLiteInt(row.FallbackUsed),
row.ErrorClass,
row.UpstreamStatus,
row.LatencyMS,
)
if err != nil {
return 0, fmt.Errorf("insert route decision log %q: %w", row.RequestID, err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted route decision log id for %q: %w", row.RequestID, err)
}
return id, nil
}
func (r *RouteDecisionLogsRepo) ListRecent(ctx context.Context, filter RouteDecisionLogFilter) ([]RouteDecisionLog, error) {
clauses := make([]string, 0, 5)
args := make([]any, 0, 6)
if requestID := strings.TrimSpace(filter.RequestID); requestID != "" {
clauses = append(clauses, "request_id = ?")
args = append(args, requestID)
}
if logicalGroupID := strings.TrimSpace(filter.LogicalGroupID); logicalGroupID != "" {
clauses = append(clauses, "logical_group_id = ?")
args = append(args, logicalGroupID)
}
if publicModel := strings.TrimSpace(filter.PublicModel); publicModel != "" {
clauses = append(clauses, "public_model = ?")
args = append(args, publicModel)
}
if selectedRouteID := strings.TrimSpace(filter.SelectedRouteID); selectedRouteID != "" {
clauses = append(clauses, "selected_route_id = ?")
args = append(args, selectedRouteID)
}
if stickyKey := strings.TrimSpace(filter.StickyKey); stickyKey != "" {
clauses = append(clauses, "sticky_key = ?")
args = append(args, stickyKey)
}
query := `SELECT id, request_id, logical_group_id, public_model, user_key, conversation_key, sticky_key, sticky_key_type, sticky_hit, selected_route_id, selected_shadow_group_id, fallback_used, error_class, upstream_status, latency_ms, created_at
FROM route_decision_logs`
if len(clauses) > 0 {
query += " WHERE " + strings.Join(clauses, " AND ")
}
query += " ORDER BY id DESC LIMIT ?"
args = append(args, normalizeRouteLogListLimit(filter.Limit))
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list route decision logs: %w", err)
}
defer rows.Close()
items := make([]RouteDecisionLog, 0)
for rows.Next() {
var (
item RouteDecisionLog
stickyHit int
fallbackUsed int
)
if err := rows.Scan(
&item.ID,
&item.RequestID,
&item.LogicalGroupID,
&item.PublicModel,
&item.UserKey,
&item.ConversationKey,
&item.StickyKey,
&item.StickyKeyType,
&stickyHit,
&item.SelectedRouteID,
&item.SelectedShadowGroupID,
&fallbackUsed,
&item.ErrorClass,
&item.UpstreamStatus,
&item.LatencyMS,
&item.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan route decision log: %w", err)
}
item.StickyHit = stickyHit != 0
item.FallbackUsed = fallbackUsed != 0
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate route decision logs: %w", err)
}
return items, nil
}
func normalizeRouteDecisionLog(row RouteDecisionLog) (RouteDecisionLog, error) {
row.RequestID = strings.TrimSpace(row.RequestID)
row.LogicalGroupID = strings.TrimSpace(row.LogicalGroupID)
row.PublicModel = strings.TrimSpace(row.PublicModel)
row.UserKey = strings.TrimSpace(row.UserKey)
row.ConversationKey = strings.TrimSpace(row.ConversationKey)
row.StickyKey = strings.TrimSpace(row.StickyKey)
row.StickyKeyType = strings.TrimSpace(row.StickyKeyType)
row.SelectedRouteID = strings.TrimSpace(row.SelectedRouteID)
row.SelectedShadowGroupID = strings.TrimSpace(row.SelectedShadowGroupID)
row.ErrorClass = strings.TrimSpace(row.ErrorClass)
switch {
case row.RequestID == "":
return RouteDecisionLog{}, fmt.Errorf("request_id is required")
case row.LogicalGroupID == "":
return RouteDecisionLog{}, fmt.Errorf("logical_group_id is required")
case row.PublicModel == "":
return RouteDecisionLog{}, fmt.Errorf("public_model is required")
case row.SelectedRouteID == "":
return RouteDecisionLog{}, fmt.Errorf("selected_route_id is required")
case row.SelectedShadowGroupID == "":
return RouteDecisionLog{}, fmt.Errorf("selected_shadow_group_id is required")
case row.UpstreamStatus < 0:
return RouteDecisionLog{}, fmt.Errorf("upstream_status must be >= 0")
case row.LatencyMS < 0:
return RouteDecisionLog{}, fmt.Errorf("latency_ms must be >= 0")
}
return row, nil
}
func boolToSQLiteInt(value bool) int {
if value {
return 1
}
return 0
}

View File

@@ -0,0 +1,163 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type RouteFailoverEvent struct {
ID int64
RequestID string
LogicalGroupID string
PublicModel string
FromRouteID string
ToRouteID string
Reason string
FailureCount int
CreatedAt string
}
type RouteFailoverEventFilter struct {
RequestID string
LogicalGroupID string
PublicModel string
FromRouteID string
ToRouteID string
Limit int
}
type RouteFailoverEventsRepo struct {
db execQuerier
}
func newRouteFailoverEventsRepo(db execQuerier) *RouteFailoverEventsRepo {
return &RouteFailoverEventsRepo{db: db}
}
func (r *RouteFailoverEventsRepo) Create(ctx context.Context, row RouteFailoverEvent) (int64, error) {
row, err := normalizeRouteFailoverEvent(row)
if err != nil {
return 0, err
}
result, err := r.db.ExecContext(
ctx,
`INSERT INTO route_failover_events (
request_id,
logical_group_id,
public_model,
from_route_id,
to_route_id,
reason,
failure_count
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
row.RequestID,
row.LogicalGroupID,
row.PublicModel,
row.FromRouteID,
row.ToRouteID,
row.Reason,
row.FailureCount,
)
if err != nil {
return 0, fmt.Errorf("insert route failover event %q: %w", row.RequestID, err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted route failover event id for %q: %w", row.RequestID, err)
}
return id, nil
}
func (r *RouteFailoverEventsRepo) ListRecent(ctx context.Context, filter RouteFailoverEventFilter) ([]RouteFailoverEvent, error) {
clauses := make([]string, 0, 5)
args := make([]any, 0, 6)
if requestID := strings.TrimSpace(filter.RequestID); requestID != "" {
clauses = append(clauses, "request_id = ?")
args = append(args, requestID)
}
if logicalGroupID := strings.TrimSpace(filter.LogicalGroupID); logicalGroupID != "" {
clauses = append(clauses, "logical_group_id = ?")
args = append(args, logicalGroupID)
}
if publicModel := strings.TrimSpace(filter.PublicModel); publicModel != "" {
clauses = append(clauses, "public_model = ?")
args = append(args, publicModel)
}
if fromRouteID := strings.TrimSpace(filter.FromRouteID); fromRouteID != "" {
clauses = append(clauses, "from_route_id = ?")
args = append(args, fromRouteID)
}
if toRouteID := strings.TrimSpace(filter.ToRouteID); toRouteID != "" {
clauses = append(clauses, "to_route_id = ?")
args = append(args, toRouteID)
}
query := `SELECT id, request_id, logical_group_id, public_model, from_route_id, to_route_id, reason, failure_count, created_at
FROM route_failover_events`
if len(clauses) > 0 {
query += " WHERE " + strings.Join(clauses, " AND ")
}
query += " ORDER BY id DESC LIMIT ?"
args = append(args, normalizeRouteLogListLimit(filter.Limit))
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list route failover events: %w", err)
}
defer rows.Close()
items := make([]RouteFailoverEvent, 0)
for rows.Next() {
var item RouteFailoverEvent
if err := rows.Scan(
&item.ID,
&item.RequestID,
&item.LogicalGroupID,
&item.PublicModel,
&item.FromRouteID,
&item.ToRouteID,
&item.Reason,
&item.FailureCount,
&item.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan route failover event: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate route failover events: %w", err)
}
return items, nil
}
func normalizeRouteFailoverEvent(row RouteFailoverEvent) (RouteFailoverEvent, error) {
row.RequestID = strings.TrimSpace(row.RequestID)
row.LogicalGroupID = strings.TrimSpace(row.LogicalGroupID)
row.PublicModel = strings.TrimSpace(row.PublicModel)
row.FromRouteID = strings.TrimSpace(row.FromRouteID)
row.ToRouteID = strings.TrimSpace(row.ToRouteID)
row.Reason = strings.TrimSpace(row.Reason)
switch {
case row.RequestID == "":
return RouteFailoverEvent{}, fmt.Errorf("request_id is required")
case row.LogicalGroupID == "":
return RouteFailoverEvent{}, fmt.Errorf("logical_group_id is required")
case row.PublicModel == "":
return RouteFailoverEvent{}, fmt.Errorf("public_model is required")
case row.FromRouteID == "":
return RouteFailoverEvent{}, fmt.Errorf("from_route_id is required")
case row.ToRouteID == "":
return RouteFailoverEvent{}, fmt.Errorf("to_route_id is required")
case row.Reason == "":
return RouteFailoverEvent{}, fmt.Errorf("reason is required")
case row.FailureCount < 0:
return RouteFailoverEvent{}, fmt.Errorf("failure_count must be >= 0")
}
return row, nil
}

View File

@@ -0,0 +1,17 @@
package sqlite
const (
defaultRouteLogListLimit = 50
maxRouteLogListLimit = 200
)
func normalizeRouteLogListLimit(limit int) int {
switch {
case limit <= 0:
return defaultRouteLogListLimit
case limit > maxRouteLogListLimit:
return maxRouteLogListLimit
default:
return limit
}
}

View File

@@ -0,0 +1,129 @@
package sqlite
import (
"context"
"testing"
)
func TestRouteDecisionLogsRepoCreateAndListRecent(t *testing.T) {
t.Parallel()
store := openTestDB(t)
ctx := context.Background()
if _, err := store.RouteDecisionLogs().Create(ctx, RouteDecisionLog{
RequestID: "req-1",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
UserKey: "user-a",
ConversationKey: "conv-a",
StickyKey: "sticky-a",
StickyKeyType: "conversation",
StickyHit: true,
SelectedRouteID: "asxs",
SelectedShadowGroupID: "gpt-shared__asxs",
FallbackUsed: false,
ErrorClass: "",
UpstreamStatus: 200,
LatencyMS: 123,
}); err != nil {
t.Fatalf("RouteDecisionLogs().Create() error = %v", err)
}
logs, err := store.RouteDecisionLogs().ListRecent(ctx, RouteDecisionLogFilter{
LogicalGroupID: "gpt-shared",
Limit: 10,
})
if err != nil {
t.Fatalf("RouteDecisionLogs().ListRecent() error = %v", err)
}
if len(logs) != 1 {
t.Fatalf("RouteDecisionLogs().ListRecent() len = %d, want 1", len(logs))
}
if logs[0].SelectedRouteID != "asxs" || !logs[0].StickyHit || logs[0].UpstreamStatus != 200 {
t.Fatalf("RouteDecisionLogs().ListRecent()[0] = %+v", logs[0])
}
}
func TestRouteFailoverEventsRepoCreateAndListRecent(t *testing.T) {
t.Parallel()
store := openTestDB(t)
ctx := context.Background()
if _, err := store.RouteFailoverEvents().Create(ctx, RouteFailoverEvent{
RequestID: "req-2",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4",
FromRouteID: "asxs",
ToRouteID: "codex2api",
Reason: "upstream_5xx",
FailureCount: 2,
}); err != nil {
t.Fatalf("RouteFailoverEvents().Create() error = %v", err)
}
items, err := store.RouteFailoverEvents().ListRecent(ctx, RouteFailoverEventFilter{
RequestID: "req-2",
Limit: 10,
})
if err != nil {
t.Fatalf("RouteFailoverEvents().ListRecent() error = %v", err)
}
if len(items) != 1 {
t.Fatalf("RouteFailoverEvents().ListRecent() len = %d, want 1", len(items))
}
if items[0].ToRouteID != "codex2api" || items[0].FailureCount != 2 {
t.Fatalf("RouteFailoverEvents().ListRecent()[0] = %+v", items[0])
}
}
func TestRouteStickyAuditRepoCreateAndListRecent(t *testing.T) {
t.Parallel()
store := openTestDB(t)
ctx := context.Background()
if _, err := store.RouteStickyAudit().Create(ctx, RouteStickyAudit{
StickyKey: "sticky-3",
StickyKeyType: "user_model",
LogicalGroupID: "gpt-shared",
PublicModel: "gpt-5.4-mini",
RouteID: "asxs",
Action: "bind",
ExpiresAt: "2026-05-28T18:00:00Z",
}); err != nil {
t.Fatalf("RouteStickyAudit().Create() error = %v", err)
}
items, err := store.RouteStickyAudit().ListRecent(ctx, RouteStickyAuditFilter{
StickyKey: "sticky-3",
Limit: 10,
})
if err != nil {
t.Fatalf("RouteStickyAudit().ListRecent() error = %v", err)
}
if len(items) != 1 {
t.Fatalf("RouteStickyAudit().ListRecent() len = %d, want 1", len(items))
}
if items[0].Action != "bind" || items[0].RouteID != "asxs" {
t.Fatalf("RouteStickyAudit().ListRecent()[0] = %+v", items[0])
}
}
func TestRouteLoggingReposRejectInvalidRows(t *testing.T) {
t.Parallel()
store := openTestDB(t)
ctx := context.Background()
if _, err := store.RouteDecisionLogs().Create(ctx, RouteDecisionLog{}); err == nil {
t.Fatal("RouteDecisionLogs().Create() error = nil, want validation error")
}
if _, err := store.RouteFailoverEvents().Create(ctx, RouteFailoverEvent{}); err == nil {
t.Fatal("RouteFailoverEvents().Create() error = nil, want validation error")
}
if _, err := store.RouteStickyAudit().Create(ctx, RouteStickyAudit{}); err == nil {
t.Fatal("RouteStickyAudit().Create() error = nil, want validation error")
}
}

View File

@@ -0,0 +1,167 @@
package sqlite
import (
"context"
"fmt"
"strings"
)
type RouteStickyAudit struct {
ID int64
StickyKey string
StickyKeyType string
LogicalGroupID string
PublicModel string
RouteID string
Action string
ExpiresAt string
CreatedAt string
}
type RouteStickyAuditFilter struct {
StickyKey string
StickyKeyType string
LogicalGroupID string
PublicModel string
RouteID string
Action string
Limit int
}
type RouteStickyAuditRepo struct {
db execQuerier
}
func newRouteStickyAuditRepo(db execQuerier) *RouteStickyAuditRepo {
return &RouteStickyAuditRepo{db: db}
}
func (r *RouteStickyAuditRepo) Create(ctx context.Context, row RouteStickyAudit) (int64, error) {
row, err := normalizeRouteStickyAudit(row)
if err != nil {
return 0, err
}
result, err := r.db.ExecContext(
ctx,
`INSERT INTO route_sticky_audit (
sticky_key,
sticky_key_type,
logical_group_id,
public_model,
route_id,
action,
expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
row.StickyKey,
row.StickyKeyType,
row.LogicalGroupID,
row.PublicModel,
row.RouteID,
row.Action,
row.ExpiresAt,
)
if err != nil {
return 0, fmt.Errorf("insert route sticky audit %q: %w", row.StickyKey, err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("read inserted route sticky audit id for %q: %w", row.StickyKey, err)
}
return id, nil
}
func (r *RouteStickyAuditRepo) ListRecent(ctx context.Context, filter RouteStickyAuditFilter) ([]RouteStickyAudit, error) {
clauses := make([]string, 0, 6)
args := make([]any, 0, 7)
if stickyKey := strings.TrimSpace(filter.StickyKey); stickyKey != "" {
clauses = append(clauses, "sticky_key = ?")
args = append(args, stickyKey)
}
if stickyKeyType := strings.TrimSpace(filter.StickyKeyType); stickyKeyType != "" {
clauses = append(clauses, "sticky_key_type = ?")
args = append(args, stickyKeyType)
}
if logicalGroupID := strings.TrimSpace(filter.LogicalGroupID); logicalGroupID != "" {
clauses = append(clauses, "logical_group_id = ?")
args = append(args, logicalGroupID)
}
if publicModel := strings.TrimSpace(filter.PublicModel); publicModel != "" {
clauses = append(clauses, "public_model = ?")
args = append(args, publicModel)
}
if routeID := strings.TrimSpace(filter.RouteID); routeID != "" {
clauses = append(clauses, "route_id = ?")
args = append(args, routeID)
}
if action := strings.TrimSpace(filter.Action); action != "" {
clauses = append(clauses, "action = ?")
args = append(args, action)
}
query := `SELECT id, sticky_key, sticky_key_type, logical_group_id, public_model, route_id, action, expires_at, created_at
FROM route_sticky_audit`
if len(clauses) > 0 {
query += " WHERE " + strings.Join(clauses, " AND ")
}
query += " ORDER BY id DESC LIMIT ?"
args = append(args, normalizeRouteLogListLimit(filter.Limit))
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list route sticky audit: %w", err)
}
defer rows.Close()
items := make([]RouteStickyAudit, 0)
for rows.Next() {
var item RouteStickyAudit
if err := rows.Scan(
&item.ID,
&item.StickyKey,
&item.StickyKeyType,
&item.LogicalGroupID,
&item.PublicModel,
&item.RouteID,
&item.Action,
&item.ExpiresAt,
&item.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan route sticky audit: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate route sticky audit: %w", err)
}
return items, nil
}
func normalizeRouteStickyAudit(row RouteStickyAudit) (RouteStickyAudit, error) {
row.StickyKey = strings.TrimSpace(row.StickyKey)
row.StickyKeyType = strings.TrimSpace(row.StickyKeyType)
row.LogicalGroupID = strings.TrimSpace(row.LogicalGroupID)
row.PublicModel = strings.TrimSpace(row.PublicModel)
row.RouteID = strings.TrimSpace(row.RouteID)
row.Action = strings.TrimSpace(row.Action)
row.ExpiresAt = strings.TrimSpace(row.ExpiresAt)
switch {
case row.StickyKey == "":
return RouteStickyAudit{}, fmt.Errorf("sticky_key is required")
case row.StickyKeyType == "":
return RouteStickyAudit{}, fmt.Errorf("sticky_key_type is required")
case row.LogicalGroupID == "":
return RouteStickyAudit{}, fmt.Errorf("logical_group_id is required")
case row.PublicModel == "":
return RouteStickyAudit{}, fmt.Errorf("public_model is required")
case row.RouteID == "":
return RouteStickyAudit{}, fmt.Errorf("route_id is required")
case row.Action == "":
return RouteStickyAudit{}, fmt.Errorf("action is required")
}
return row, nil
}