281 lines
8.8 KiB
Go
281 lines
8.8 KiB
Go
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
|
|
}
|