Files
ez-api/internal/api/handler.go
zenfun 305f2ebf18 feat(provider): add update endpoint and enforce status checks
Add `PUT /admin/providers/{id}` endpoint to allow updating provider
configurations, including status and ban details. Update synchronization
logic to exclude inactive or banned providers from routing tables to
ensure traffic is not routed to them.
2025-12-12 23:44:52 +08:00

344 lines
9.9 KiB
Go

package api
import (
"net/http"
"strconv"
"strings"
"github.com/ez-api/ez-api/internal/dto"
"github.com/ez-api/ez-api/internal/model"
"github.com/ez-api/ez-api/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Handler struct {
db *gorm.DB
sync *service.SyncService
logger *service.LogWriter
}
func NewHandler(db *gorm.DB, sync *service.SyncService, logger *service.LogWriter) *Handler {
return &Handler{db: db, sync: sync, logger: logger}
}
// CreateKey is now handled by MasterHandler
// CreateProvider godoc
// @Summary Create a new provider
// @Description Register a new upstream AI provider
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param provider body dto.ProviderDTO true "Provider Info"
// @Success 201 {object} model.Provider
// @Failure 400 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/providers [post]
func (h *Handler) CreateProvider(c *gin.Context) {
var req dto.ProviderDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
group := strings.TrimSpace(req.Group)
if group == "" {
group = "default"
}
status := strings.TrimSpace(req.Status)
if status == "" {
status = "active"
}
autoBan := true
if req.AutoBan != nil {
autoBan = *req.AutoBan
}
provider := model.Provider{
Name: req.Name,
Type: req.Type,
BaseURL: req.BaseURL,
APIKey: req.APIKey,
Group: group,
Models: strings.Join(req.Models, ","),
Status: status,
AutoBan: autoBan,
BanReason: req.BanReason,
}
if !req.BanUntil.IsZero() {
tu := req.BanUntil.UTC()
provider.BanUntil = &tu
}
if err := h.db.Create(&provider).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider", "details": err.Error()})
return
}
if err := h.sync.SyncProvider(&provider); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync provider", "details": err.Error()})
return
}
c.JSON(http.StatusCreated, provider)
}
// UpdateProvider godoc
// @Summary Update a provider
// @Description Update provider attributes including status/auto-ban flags
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param id path int true "Provider ID"
// @Param provider body dto.ProviderDTO true "Provider Info"
// @Success 200 {object} model.Provider
// @Failure 400 {object} gin.H
// @Failure 404 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/providers/{id} [put]
func (h *Handler) UpdateProvider(c *gin.Context) {
idParam := c.Param("id")
id, err := strconv.Atoi(idParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var existing model.Provider
if err := h.db.First(&existing, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
var req dto.ProviderDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
update := map[string]any{}
if strings.TrimSpace(req.Name) != "" {
update["name"] = req.Name
}
if strings.TrimSpace(req.Type) != "" {
update["type"] = req.Type
}
if strings.TrimSpace(req.BaseURL) != "" {
update["base_url"] = req.BaseURL
}
if req.APIKey != "" {
update["api_key"] = req.APIKey
}
if req.Models != nil {
update["models"] = strings.Join(req.Models, ",")
}
if strings.TrimSpace(req.Group) != "" {
update["group"] = normalizeGroup(req.Group)
}
if req.AutoBan != nil {
update["auto_ban"] = *req.AutoBan
}
if strings.TrimSpace(req.Status) != "" {
update["status"] = req.Status
}
if req.BanReason != "" || strings.TrimSpace(req.Status) == "active" {
update["ban_reason"] = req.BanReason
}
if !req.BanUntil.IsZero() {
tu := req.BanUntil.UTC()
update["ban_until"] = &tu
}
if req.BanUntil.IsZero() && strings.TrimSpace(req.Status) == "active" {
update["ban_until"] = nil
}
if len(update) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.db.Model(&existing).Updates(update).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider", "details": err.Error()})
return
}
if err := h.db.First(&existing, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload provider", "details": err.Error()})
return
}
if err := h.sync.SyncProvider(&existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync provider", "details": err.Error()})
return
}
c.JSON(http.StatusOK, existing)
}
// CreateModel godoc
// @Summary Register a new model
// @Description Register a supported model with its capabilities
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param model body dto.ModelDTO true "Model Info"
// @Success 201 {object} model.Model
// @Failure 400 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/models [post]
func (h *Handler) CreateModel(c *gin.Context) {
var req dto.ModelDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
modelReq := model.Model{
Name: req.Name,
ContextWindow: req.ContextWindow,
CostPerToken: req.CostPerToken,
SupportsVision: req.SupportsVision,
SupportsFunctions: req.SupportsFunctions,
SupportsToolChoice: req.SupportsToolChoice,
SupportsFIM: req.SupportsFIM,
MaxOutputTokens: req.MaxOutputTokens,
}
if err := h.db.Create(&modelReq).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create model", "details": err.Error()})
return
}
if err := h.sync.SyncModel(&modelReq); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync model", "details": err.Error()})
return
}
c.JSON(http.StatusCreated, modelReq)
}
// ListModels godoc
// @Summary List all models
// @Description Get a list of all registered models
// @Tags admin
// @Produce json
// @Security AdminAuth
// @Success 200 {array} model.Model
// @Failure 500 {object} gin.H
// @Router /admin/models [get]
func (h *Handler) ListModels(c *gin.Context) {
var models []model.Model
if err := h.db.Find(&models).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list models", "details": err.Error()})
return
}
c.JSON(http.StatusOK, models)
}
// UpdateModel godoc
// @Summary Update a model
// @Description Update an existing model's configuration
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminAuth
// @Param id path int true "Model ID"
// @Param model body dto.ModelDTO true "Model Info"
// @Success 200 {object} model.Model
// @Failure 400 {object} gin.H
// @Failure 404 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/models/{id} [put]
func (h *Handler) UpdateModel(c *gin.Context) {
idParam := c.Param("id")
id, err := strconv.Atoi(idParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req dto.ModelDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var existing model.Model
if err := h.db.First(&existing, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
existing.Name = req.Name
existing.ContextWindow = req.ContextWindow
existing.CostPerToken = req.CostPerToken
existing.SupportsVision = req.SupportsVision
existing.SupportsFunctions = req.SupportsFunctions
existing.SupportsToolChoice = req.SupportsToolChoice
existing.SupportsFIM = req.SupportsFIM
existing.MaxOutputTokens = req.MaxOutputTokens
if err := h.db.Save(&existing).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update model", "details": err.Error()})
return
}
if err := h.sync.SyncModel(&existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync model", "details": err.Error()})
return
}
c.JSON(http.StatusOK, existing)
}
// SyncSnapshot godoc
// @Summary Force sync snapshot
// @Description Force full synchronization of DB state to Redis
// @Tags admin
// @Produce json
// @Security AdminAuth
// @Success 200 {object} gin.H
// @Failure 500 {object} gin.H
// @Router /admin/sync/snapshot [post]
func (h *Handler) SyncSnapshot(c *gin.Context) {
if err := h.sync.SyncAll(h.db); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync snapshots", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "synced"})
}
// IngestLog accepts log records from data plane or other services.
// @Summary Ingest logs
// @Description Internal endpoint for ingesting logs from Balancer
// @Tags system
// @Accept json
// @Produce json
// @Param log body model.LogRecord true "Log Record"
// @Success 202 {object} gin.H
// @Failure 400 {object} gin.H
// @Router /logs [post]
func (h *Handler) IngestLog(c *gin.Context) {
var rec model.LogRecord
if err := c.ShouldBindJSON(&rec); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// By default, only metadata is expected; payload fields may be empty.
h.logger.Write(rec)
c.JSON(http.StatusAccepted, gin.H{"status": "queued"})
}
func normalizeGroup(group string) string {
if strings.TrimSpace(group) == "" {
return "default"
}
return group
}