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/ez-api/foundation/provider" "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 } providerType := provider.NormalizeType(req.Type) googleLocation := provider.DefaultGoogleLocation(providerType, req.GoogleLocation) 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: strings.TrimSpace(req.Type), BaseURL: req.BaseURL, APIKey: req.APIKey, GoogleProject: strings.TrimSpace(req.GoogleProject), GoogleLocation: googleLocation, 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 } nextType := strings.TrimSpace(existing.Type) if t := strings.TrimSpace(req.Type); t != "" { nextType = t } nextTypeLower := provider.NormalizeType(nextType) update := map[string]any{} if strings.TrimSpace(req.Name) != "" { update["name"] = req.Name } if strings.TrimSpace(req.Type) != "" { update["type"] = strings.TrimSpace(req.Type) } if strings.TrimSpace(req.BaseURL) != "" { update["base_url"] = req.BaseURL } if req.APIKey != "" { update["api_key"] = req.APIKey } if strings.TrimSpace(req.GoogleProject) != "" { update["google_project"] = strings.TrimSpace(req.GoogleProject) } if strings.TrimSpace(req.GoogleLocation) != "" { update["google_location"] = strings.TrimSpace(req.GoogleLocation) } else if provider.IsVertexFamily(nextTypeLower) && strings.TrimSpace(existing.GoogleLocation) == "" { update["google_location"] = provider.DefaultGoogleLocation(nextTypeLower, "") } 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 }