package handlers import ( "net/http" "strconv" "github.com/gin-gonic/gin" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" "git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Health Admin Handler ──────────────────── type HealthAdminHandler struct { healthStore health.Store stores store.Stores } func NewHealthAdminHandler(hs health.Store, stores store.Stores) *HealthAdminHandler { return &HealthAdminHandler{healthStore: hs, stores: stores} } // GetProviderHealth returns health metrics for a single provider. // GET /api/v1/admin/providers/:id/health?hours=24 func (h *HealthAdminHandler) GetProviderHealth(c *gin.Context) { providerID := c.Param("id") hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24")) if hours < 1 || hours > 168 { hours = 24 } windows, err := h.healthStore.ListWindows(c.Request.Context(), providerID, hours) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch health data"}) return } // Derive current status from most recent window var summary models.ProviderHealthSummary summary.ProviderConfigID = providerID if len(windows) > 0 { latest := windows[0] summary.RequestCount = latest.RequestCount summary.ErrorRate = latest.ErrorRate() summary.AvgLatencyMs = latest.AvgLatencyMs() summary.MaxLatencyMs = latest.MaxLatencyMs summary.LastError = latest.LastError summary.LastErrorAt = latest.LastErrorAt summary.Status = health.DeriveStatus(summary.ErrorRate, summary.RequestCount) } else { summary.Status = models.StatusUnknown } SafeJSON(c, http.StatusOK, gin.H{ "summary": summary, "windows": windows, }) } // GetAllProviderHealth returns current health for all providers. // GET /api/v1/admin/providers/health func (h *HealthAdminHandler) GetAllProviderHealth(c *gin.Context) { windows, err := h.healthStore.ListAllCurrent(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch health data"}) return } // Build summaries summaries := make([]models.ProviderHealthSummary, 0, len(windows)) for _, w := range windows { s := models.ProviderHealthSummary{ ProviderConfigID: w.ProviderConfigID, Status: health.DeriveStatus(w.ErrorRate(), w.RequestCount), RequestCount: w.RequestCount, ErrorRate: w.ErrorRate(), ErrorCount: w.ErrorCount, RateLimitCount: w.RateLimitCount, TimeoutCount: w.TimeoutCount, AvgLatencyMs: w.AvgLatencyMs(), MaxLatencyMs: w.MaxLatencyMs, LastError: w.LastError, LastErrorAt: w.LastErrorAt, } summaries = append(summaries, s) } SafeJSON(c, http.StatusOK, gin.H{ "data": summaries, }) } // ── Capability Override Admin Handler ──────── type CapOverrideAdminHandler struct { stores store.Stores } func NewCapOverrideAdminHandler(stores store.Stores) *CapOverrideAdminHandler { return &CapOverrideAdminHandler{stores: stores} } // GetModelCapabilities returns resolved capabilities with source annotation. // GET /api/v1/admin/models/:id/capabilities?provider_config_id=... func (h *CapOverrideAdminHandler) GetModelCapabilities(c *gin.Context) { modelID := c.Param("id") providerConfigID := c.Query("provider_config_id") // Get catalog capabilities var catalogCaps *models.ModelCapabilities if providerConfigID != "" { if entry, err := h.stores.Catalog.GetByModelID(c.Request.Context(), providerConfigID, modelID); err == nil { catalogCaps = &entry.Capabilities } } else { if entry, err := h.stores.Catalog.GetByModelIDAny(c.Request.Context(), modelID); err == nil { catalogCaps = &entry.Capabilities } } // Get admin overrides var overrides []models.CapabilityOverride var err error if providerConfigID != "" { overrides, err = h.stores.CapOverrides.ListForProviderModel(c.Request.Context(), providerConfigID, modelID) } else { overrides, err = h.stores.CapOverrides.ListForModel(c.Request.Context(), modelID) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch overrides"}) return } // Resolve through three tiers heuristic := capspkg.InferCapabilities(modelID) resolved := capspkg.ResolveIntrinsic(modelID, catalogCaps, overrides) // Build source annotations sources := buildSourceAnnotations(catalogCaps, &heuristic, overrides) SafeJSON(c, http.StatusOK, gin.H{ "model_id": modelID, "resolved": resolved, "catalog": catalogCaps, "heuristic": heuristic, "overrides": overrides, "sources": sources, }) } // SetModelCapability sets an admin override for a specific capability. // PUT /api/v1/admin/models/:id/capabilities func (h *CapOverrideAdminHandler) SetModelCapability(c *gin.Context) { modelID := c.Param("id") userID := c.GetString("user_id") var req struct { ProviderConfigID *string `json:"provider_config_id"` Field string `json:"field" binding:"required"` Value string `json:"value" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate field name validFields := map[string]bool{ "streaming": true, "tool_calling": true, "vision": true, "thinking": true, "reasoning": true, "code_optimized": true, "web_search": true, "max_context": true, "max_output_tokens": true, } if !validFields[req.Field] { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid capability field: " + req.Field}) return } // Validate value switch req.Field { case "max_context", "max_output_tokens": if _, err := strconv.Atoi(req.Value); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "value must be a number for " + req.Field}) return } default: if req.Value != "true" && req.Value != "false" { c.JSON(http.StatusBadRequest, gin.H{"error": "value must be 'true' or 'false' for " + req.Field}) return } } override := &models.CapabilityOverride{ ProviderConfigID: req.ProviderConfigID, ModelID: modelID, Field: req.Field, Value: req.Value, SetBy: &userID, } if err := h.stores.CapOverrides.Set(c.Request.Context(), override); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set override"}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // DeleteModelCapability removes a specific override. // DELETE /api/v1/admin/models/:id/capabilities/:overrideId func (h *CapOverrideAdminHandler) DeleteModelCapability(c *gin.Context) { overrideID := c.Param("overrideId") if err := h.stores.CapOverrides.Delete(c.Request.Context(), overrideID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete override"}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } // ListAllOverrides returns all capability overrides (admin view). // GET /api/v1/admin/capability-overrides func (h *CapOverrideAdminHandler) ListAllOverrides(c *gin.Context) { overrides, err := h.stores.CapOverrides.ListAll(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch overrides"}) return } SafeJSON(c, http.StatusOK, gin.H{ "data": overrides, "total": len(overrides), }) } // ── Source Annotation Helpers ──────────────── type capSource struct { Field string `json:"field"` Source string `json:"source"` // "catalog", "heuristic", "override" } func buildSourceAnnotations(catalog *models.ModelCapabilities, heuristic *models.ModelCapabilities, overrides []models.CapabilityOverride) []capSource { // Build override set for quick lookup overrideSet := make(map[string]bool, len(overrides)) for _, o := range overrides { overrideSet[o.Field] = true } fields := []struct { name string hasCatalog bool hasHeur bool }{ {"streaming", catalog != nil && catalog.Streaming, heuristic.Streaming}, {"tool_calling", catalog != nil && catalog.ToolCalling, heuristic.ToolCalling}, {"vision", catalog != nil && catalog.Vision, heuristic.Vision}, {"thinking", catalog != nil && catalog.Thinking, heuristic.Thinking}, {"reasoning", catalog != nil && catalog.Reasoning, heuristic.Reasoning}, {"code_optimized", catalog != nil && catalog.CodeOptimized, heuristic.CodeOptimized}, {"web_search", catalog != nil && catalog.WebSearch, heuristic.WebSearch}, {"max_context", catalog != nil && catalog.MaxContext > 0, heuristic.MaxContext > 0}, {"max_output_tokens", catalog != nil && catalog.MaxOutputTokens > 0, heuristic.MaxOutputTokens > 0}, } var sources []capSource for _, f := range fields { src := "default" if overrideSet[f.name] { src = "override" } else if f.hasCatalog { src = "catalog" } else if f.hasHeur { src = "heuristic" } sources = append(sources, capSource{Field: f.name, Source: src}) } return sources } // ── Provider Types Endpoint ───────────────── // GetProviderTypes returns metadata and profile schemas for all registered // provider types. Used by the admin UI to render provider creation forms // and show available settings. func GetProviderTypes(c *gin.Context) { types := providers.ListTypes() c.JSON(http.StatusOK, gin.H{"data": types}) }