This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/health_admin.go
Jeffrey Smith 11fd8c1e57 step 1: rename module chat-switchboard → switchboard-core
- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
2026-03-25 19:48:04 -04:00

295 lines
9.2 KiB
Go

package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
capspkg "switchboard-core/capabilities"
"switchboard-core/health"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/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})
}