Changeset 0.22.0.1 (#94)
This commit is contained in:
280
server/handlers/health_admin.go
Normal file
280
server/handlers/health_admin.go
Normal file
@@ -0,0 +1,280 @@
|
||||
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/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(),
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user