package handlers import ( "context" "net/http" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/routing" "git.gobha.me/xcaliber/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 }