package api import ( "context" "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" groupx "github.com/ez-api/foundation/group" "github.com/ez-api/foundation/provider" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) type Handler struct { db *gorm.DB logDB *gorm.DB sync *service.SyncService logger *service.LogWriter rdb *redis.Client logWebhook *service.LogWebhookService logPartitioner *service.LogPartitioner } func NewHandler(db *gorm.DB, logDB *gorm.DB, sync *service.SyncService, logger *service.LogWriter, rdb *redis.Client, partitioner *service.LogPartitioner) *Handler { if logDB == nil { logDB = db } return &Handler{ db: db, logDB: logDB, sync: sync, logger: logger, rdb: rdb, logWebhook: service.NewLogWebhookService(rdb), logPartitioner: partitioner, } } func (h *Handler) logDBConn() *gorm.DB { if h == nil || h.logDB == nil { return h.db } return h.logDB } func (h *Handler) logBaseQuery() *gorm.DB { return logBaseQuery(h.logDBConn(), h.logPartitioner) } // 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) baseURL := strings.TrimSpace(req.BaseURL) 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 } // CP-side defaults + validation to prevent DP runtime errors. switch providerType { case provider.TypeOpenAI: if baseURL == "" { baseURL = "https://api.openai.com/v1" } case provider.TypeAnthropic, provider.TypeClaude: if baseURL == "" { baseURL = "https://api.anthropic.com" } case provider.TypeCompatible: if baseURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "base_url required for compatible providers"}) return } default: // Google SDK providers: base_url is not required. if provider.IsVertexFamily(providerType) && strings.TrimSpace(googleLocation) == "" { googleLocation = provider.DefaultGoogleLocation(providerType, "") } // For Gemini API providers, api_key is required. if provider.IsGoogleFamily(providerType) && !provider.IsVertexFamily(providerType) && strings.TrimSpace(req.APIKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required for gemini api providers"}) return } } provider := model.Provider{ Name: req.Name, Type: strings.TrimSpace(req.Type), BaseURL: 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, Weight: req.Weight, } 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 } // Provider model list changes can affect binding upstream mappings; rebuild bindings snapshot. if err := h.sync.SyncBindings(h.db); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync bindings", "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) nextBaseURL := strings.TrimSpace(existing.BaseURL) if strings.TrimSpace(req.BaseURL) != "" { nextBaseURL = strings.TrimSpace(req.BaseURL) } 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 req.Weight > 0 { update["weight"] = req.Weight } if strings.TrimSpace(req.Group) != "" { update["group"] = groupx.Normalize(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 } // Defaults/validation after considering intended type/base_url. switch nextTypeLower { case provider.TypeOpenAI: if nextBaseURL == "" { update["base_url"] = "https://api.openai.com/v1" } case provider.TypeAnthropic, provider.TypeClaude: if nextBaseURL == "" { update["base_url"] = "https://api.anthropic.com" } case provider.TypeCompatible: if nextBaseURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "base_url required for compatible providers"}) return } default: if provider.IsGoogleFamily(nextTypeLower) && !provider.IsVertexFamily(nextTypeLower) { // Ensure Gemini API providers have api_key. // If update does not include api_key, keep existing; otherwise require new one not empty. apiKey := existing.APIKey if req.APIKey != "" { apiKey = req.APIKey } if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required for gemini api providers"}) return } } } 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 } if err := h.sync.SyncBindings(h.db); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync bindings", "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 } name := strings.TrimSpace(req.Name) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name required"}) return } kind := strings.ToLower(strings.TrimSpace(req.Kind)) if kind == "" { kind = "chat" } switch kind { case "chat", "embedding", "rerank", "other": default: c.JSON(http.StatusBadRequest, gin.H{"error": "invalid kind"}) return } modelReq := model.Model{ Name: name, Kind: kind, 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 // @Param page query int false "page (1-based)" // @Param limit query int false "limit (default 50, max 200)" // @Param search query string false "search by name/kind" // @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 q := h.db.Model(&model.Model{}).Order("id desc") query := parseListQuery(c) q = applyListSearch(q, query.Search, "name", "kind") q = applyListPagination(q, query) if err := q.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 } name := strings.TrimSpace(req.Name) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name required"}) return } kind := strings.ToLower(strings.TrimSpace(req.Kind)) if kind == "" { kind = strings.ToLower(strings.TrimSpace(existing.Kind)) } if kind == "" { kind = "chat" } switch kind { case "chat", "embedding", "rerank", "other": default: c.JSON(http.StatusBadRequest, gin.H{"error": "invalid kind"}) return } existing.Name = name existing.Kind = kind 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) } // DeleteModel godoc // @Summary Delete a model // @Description Delete a model by id // @Tags admin // @Produce json // @Security AdminAuth // @Param id path int true "Model ID" // @Success 200 {object} gin.H // @Failure 400 {object} gin.H // @Failure 404 {object} gin.H // @Failure 500 {object} gin.H // @Router /admin/models/{id} [delete] func (h *Handler) DeleteModel(c *gin.Context) { idParam := c.Param("id") id, err := strconv.Atoi(idParam) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) 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 } if err := h.db.Delete(&existing).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete model", "details": err.Error()}) return } if err := h.sync.SyncModelDelete(&existing); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync model delete", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "deleted"}) } // 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 } if !h.logRequestBodyEnabled(c.Request.Context()) { rec.RequestBody = "" } // By default, only metadata is expected; payload fields may be empty. if h.logger != nil { h.logger.Write(rec) } if h.logWebhook != nil { recCopy := rec go h.logWebhook.NotifyIfNeeded(context.Background(), recCopy) } c.JSON(http.StatusAccepted, gin.H{"status": "queued"}) } func (h *Handler) logRequestBodyEnabled(ctx context.Context) bool { if h == nil || h.rdb == nil { return true } raw, err := h.rdb.HGet(ctx, featuresKey, logRequestBodyFeatureKey).Result() if err == redis.Nil || strings.TrimSpace(raw) == "" { return true } if err != nil { return true } return strings.EqualFold(raw, "true") || strings.EqualFold(raw, "1") }