243 lines
7.0 KiB
Go
243 lines
7.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"chat-switchboard/health"
|
|
"chat-switchboard/models"
|
|
"chat-switchboard/routing"
|
|
"chat-switchboard/store"
|
|
)
|
|
|
|
// ── Routing Admin Handler ───────────────────
|
|
|
|
type RoutingAdminHandler struct {
|
|
stores store.Stores
|
|
evaluator *routing.Evaluator
|
|
healthStore health.Store
|
|
}
|
|
|
|
func NewRoutingAdminHandler(stores store.Stores, evaluator *routing.Evaluator, hs health.Store) *RoutingAdminHandler {
|
|
return &RoutingAdminHandler{stores: stores, evaluator: evaluator, healthStore: hs}
|
|
}
|
|
|
|
// ── CRUD ────────────────────────────────────
|
|
|
|
// ListPolicies returns all routing policies.
|
|
// GET /api/v1/admin/routing/policies
|
|
func (h *RoutingAdminHandler) ListPolicies(c *gin.Context) {
|
|
if h.stores.RoutingPolicies == nil {
|
|
c.JSON(http.StatusOK, gin.H{"data": []models.RoutingPolicy{}})
|
|
return
|
|
}
|
|
|
|
policies, err := h.stores.RoutingPolicies.ListAll(context.Background())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list policies: " + err.Error()})
|
|
return
|
|
}
|
|
if policies == nil {
|
|
policies = []models.RoutingPolicy{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": policies})
|
|
}
|
|
|
|
// GetPolicy returns a single routing policy.
|
|
// GET /api/v1/admin/routing/policies/:id
|
|
func (h *RoutingAdminHandler) GetPolicy(c *gin.Context) {
|
|
if h.stores.RoutingPolicies == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "routing not available"})
|
|
return
|
|
}
|
|
|
|
p, err := h.stores.RoutingPolicies.GetByID(context.Background(), c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, p)
|
|
}
|
|
|
|
// CreatePolicy creates a new routing policy.
|
|
// POST /api/v1/admin/routing/policies
|
|
func (h *RoutingAdminHandler) CreatePolicy(c *gin.Context) {
|
|
if h.stores.RoutingPolicies == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
|
|
return
|
|
}
|
|
|
|
var p models.RoutingPolicy
|
|
if err := c.ShouldBindJSON(&p); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate policy type
|
|
switch routing.PolicyType(p.Type) {
|
|
case routing.PolicyProviderPrefer, routing.PolicyTeamRoute,
|
|
routing.PolicyCostLimit, routing.PolicyModelAlias,
|
|
routing.PolicyCapabilityMatch:
|
|
// valid
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy_type: " + p.Type})
|
|
return
|
|
}
|
|
|
|
// Validate scope
|
|
if p.Scope != "global" && p.Scope != "team" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be 'global' or 'team'"})
|
|
return
|
|
}
|
|
if p.Scope == "team" && (p.TeamID == nil || *p.TeamID == "") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team-scoped policies"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.RoutingPolicies.Create(context.Background(), &p); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create policy: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, p)
|
|
}
|
|
|
|
// UpdatePolicy updates an existing routing policy.
|
|
// PUT /api/v1/admin/routing/policies/:id
|
|
func (h *RoutingAdminHandler) UpdatePolicy(c *gin.Context) {
|
|
if h.stores.RoutingPolicies == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
|
|
return
|
|
}
|
|
|
|
id := c.Param("id")
|
|
|
|
// Verify exists
|
|
if _, err := h.stores.RoutingPolicies.GetByID(context.Background(), id); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"})
|
|
return
|
|
}
|
|
|
|
var p models.RoutingPolicy
|
|
if err := c.ShouldBindJSON(&p); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
p.ID = id
|
|
|
|
if err := h.stores.RoutingPolicies.Update(context.Background(), &p); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, p)
|
|
}
|
|
|
|
// DeletePolicy removes a routing policy.
|
|
// DELETE /api/v1/admin/routing/policies/:id
|
|
func (h *RoutingAdminHandler) DeletePolicy(c *gin.Context) {
|
|
if h.stores.RoutingPolicies == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.RoutingPolicies.Delete(context.Background(), c.Param("id")); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete policy: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
|
}
|
|
|
|
// ── Dry-Run Test ────────────────────────────
|
|
|
|
type routingTestRequest struct {
|
|
Model string `json:"model" binding:"required"`
|
|
UserID string `json:"user_id" binding:"required"`
|
|
TeamIDs []string `json:"team_ids,omitempty"`
|
|
}
|
|
|
|
// TestRouting performs a dry-run policy evaluation for a given model + user.
|
|
// POST /api/v1/admin/routing/test
|
|
func (h *RoutingAdminHandler) TestRouting(c *gin.Context) {
|
|
if h.stores.RoutingPolicies == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
|
|
return
|
|
}
|
|
|
|
var req routingTestRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Load active policies
|
|
dbPolicies, err := h.stores.RoutingPolicies.ListActive(context.Background())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load policies"})
|
|
return
|
|
}
|
|
policies := routing.FromModels(dbPolicies)
|
|
|
|
// Build candidates from all active provider configs
|
|
candidates, err := h.buildCandidates(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build candidates: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Build health status
|
|
healthStatus := make(map[string]models.ProviderStatus)
|
|
if h.healthStore != nil {
|
|
windows, _ := h.healthStore.ListAllCurrent(context.Background())
|
|
for _, w := range windows {
|
|
healthStatus[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount)
|
|
}
|
|
}
|
|
|
|
ctx := &routing.Context{
|
|
UserID: req.UserID,
|
|
TeamIDs: req.TeamIDs,
|
|
Model: req.Model,
|
|
HealthStatus: healthStatus,
|
|
Pricing: map[string]*models.ModelPricing{},
|
|
}
|
|
|
|
result, decision := h.evaluator.Evaluate(ctx, policies, candidates)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"candidates": result,
|
|
"decision": decision,
|
|
"policies": len(policies),
|
|
})
|
|
}
|
|
|
|
// buildCandidates creates routing candidates from all active provider configs.
|
|
func (h *RoutingAdminHandler) buildCandidates(c *gin.Context) ([]routing.Candidate, error) {
|
|
if h.stores.Providers == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// For dry-run, list all global configs (admin scope).
|
|
configs, err := h.stores.Providers.ListGlobal(context.Background())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
candidates := make([]routing.Candidate, 0, len(configs))
|
|
for _, cfg := range configs {
|
|
if !cfg.IsActive {
|
|
continue
|
|
}
|
|
candidates = append(candidates, routing.Candidate{
|
|
ConfigID: cfg.ID,
|
|
ProviderID: cfg.Provider,
|
|
Model: "", // model is applied per-policy or from request
|
|
Endpoint: cfg.Endpoint,
|
|
})
|
|
}
|
|
return candidates, nil
|
|
}
|