diff --git a/cmd/server/main.go b/cmd/server/main.go index 88f9d24..e2f0c09 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -111,8 +111,8 @@ func main() { healthService := service.NewHealthCheckService(db, rdb) handler := api.NewHandler(db, syncService, logWriter) - adminHandler := api.NewAdminHandler(masterService, syncService) - masterHandler := api.NewMasterHandler(masterService, syncService) + adminHandler := api.NewAdminHandler(db, masterService, syncService) + masterHandler := api.NewMasterHandler(db, masterService, syncService) featureHandler := api.NewFeatureHandler(rdb) modelRegistryService := service.NewModelRegistryService(db, rdb, service.ModelRegistryConfig{ Enabled: cfg.ModelRegistry.Enabled, @@ -168,6 +168,11 @@ func main() { adminGroup.Use(middleware.AdminAuthMiddleware(adminService)) { adminGroup.POST("/masters", adminHandler.CreateMaster) + adminGroup.GET("/masters", adminHandler.ListMasters) + adminGroup.GET("/masters/:id", adminHandler.GetMaster) + adminGroup.PUT("/masters/:id", adminHandler.UpdateMaster) + adminGroup.DELETE("/masters/:id", adminHandler.DeleteMaster) + adminGroup.POST("/masters/:id/manage", adminHandler.ManageMaster) adminGroup.POST("/masters/:id/keys", adminHandler.IssueChildKeyForMaster) adminGroup.GET("/masters/:id/access", handler.GetMasterAccess) adminGroup.PUT("/masters/:id/access", handler.UpdateMasterAccess) @@ -180,13 +185,19 @@ func main() { adminGroup.POST("/model-registry/rollback", modelRegistryHandler.Rollback) // Other admin routes for managing providers, models, etc. adminGroup.POST("/providers", handler.CreateProvider) + adminGroup.GET("/providers", handler.ListProviders) + adminGroup.GET("/providers/:id", handler.GetProvider) adminGroup.POST("/providers/preset", handler.CreateProviderPreset) adminGroup.POST("/providers/custom", handler.CreateProviderCustom) adminGroup.POST("/providers/google", handler.CreateProviderGoogle) adminGroup.PUT("/providers/:id", handler.UpdateProvider) + adminGroup.DELETE("/providers/:id", handler.DeleteProvider) + adminGroup.POST("/providers/:id/test", handler.TestProvider) adminGroup.POST("/models", handler.CreateModel) adminGroup.GET("/models", handler.ListModels) adminGroup.PUT("/models/:id", handler.UpdateModel) + adminGroup.GET("/logs", handler.ListLogs) + adminGroup.GET("/logs/stats", handler.LogStats) adminGroup.POST("/bindings", handler.CreateBinding) adminGroup.GET("/bindings", handler.ListBindings) adminGroup.GET("/bindings/:id", handler.GetBinding) @@ -199,7 +210,14 @@ func main() { masterGroup := r.Group("/v1") masterGroup.Use(middleware.MasterAuthMiddleware(masterService)) { + masterGroup.GET("/self", masterHandler.GetSelf) masterGroup.POST("/tokens", masterHandler.IssueChildKey) + masterGroup.GET("/tokens", masterHandler.ListTokens) + masterGroup.GET("/tokens/:id", masterHandler.GetToken) + masterGroup.PUT("/tokens/:id", masterHandler.UpdateToken) + masterGroup.DELETE("/tokens/:id", masterHandler.DeleteToken) + masterGroup.GET("/logs", masterHandler.ListSelfLogs) + masterGroup.GET("/stats", masterHandler.GetSelfStats) } // Public/General Routes (if any) diff --git a/internal/api/admin_handler.go b/internal/api/admin_handler.go index b68fc31..d062744 100644 --- a/internal/api/admin_handler.go +++ b/internal/api/admin_handler.go @@ -6,17 +6,20 @@ import ( "strconv" "strings" + "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 AdminHandler struct { + db *gorm.DB masterService *service.MasterService syncService *service.SyncService } -func NewAdminHandler(masterService *service.MasterService, syncService *service.SyncService) *AdminHandler { - return &AdminHandler{masterService: masterService, syncService: syncService} +func NewAdminHandler(db *gorm.DB, masterService *service.MasterService, syncService *service.SyncService) *AdminHandler { + return &AdminHandler{db: db, masterService: masterService, syncService: syncService} } type CreateMasterRequest struct { @@ -74,6 +77,320 @@ func (h *AdminHandler) CreateMaster(c *gin.Context) { }) } +type MasterView struct { + ID uint `json:"id"` + Name string `json:"name"` + Group string `json:"group"` + DefaultNamespace string `json:"default_namespace"` + Namespaces string `json:"namespaces"` + Epoch int64 `json:"epoch"` + Status string `json:"status"` + MaxChildKeys int `json:"max_child_keys"` + GlobalQPS int `json:"global_qps"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func toMasterView(m model.Master) MasterView { + return MasterView{ + ID: m.ID, + Name: m.Name, + Group: m.Group, + DefaultNamespace: strings.TrimSpace(m.DefaultNamespace), + Namespaces: strings.TrimSpace(m.Namespaces), + Epoch: m.Epoch, + Status: m.Status, + MaxChildKeys: m.MaxChildKeys, + GlobalQPS: m.GlobalQPS, + CreatedAt: m.CreatedAt.UTC().Unix(), + UpdatedAt: m.UpdatedAt.UTC().Unix(), + } +} + +// ListMasters godoc +// @Summary List masters +// @Description List all master tenants +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Success 200 {array} MasterView +// @Failure 500 {object} gin.H +// @Router /admin/masters [get] +func (h *AdminHandler) ListMasters(c *gin.Context) { + var masters []model.Master + if err := h.db.Order("id desc").Find(&masters).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list masters", "details": err.Error()}) + return + } + out := make([]MasterView, 0, len(masters)) + for _, m := range masters { + out = append(out, toMasterView(m)) + } + c.JSON(http.StatusOK, out) +} + +// GetMaster godoc +// @Summary Get master +// @Description Get a master tenant by id +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Master ID" +// @Success 200 {object} MasterView +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/masters/{id} [get] +func (h *AdminHandler) GetMaster(c *gin.Context) { + idRaw := strings.TrimSpace(c.Param("id")) + idU64, err := strconv.ParseUint(idRaw, 10, 64) + if err != nil || idU64 == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"}) + return + } + var m model.Master + if err := h.db.First(&m, uint(idU64)).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "master not found"}) + return + } + c.JSON(http.StatusOK, toMasterView(m)) +} + +type UpdateMasterRequest struct { + Name *string `json:"name,omitempty"` + Group *string `json:"group,omitempty"` + MaxChildKeys *int `json:"max_child_keys,omitempty"` + GlobalQPS *int `json:"global_qps,omitempty"` + PropagateToKeys bool `json:"propagate_to_keys,omitempty"` +} + +// UpdateMaster godoc +// @Summary Update master +// @Description Update master fields; optionally propagate group to existing keys +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param id path int true "Master ID" +// @Param request body UpdateMasterRequest true "Update payload" +// @Success 200 {object} MasterView +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/masters/{id} [put] +func (h *AdminHandler) UpdateMaster(c *gin.Context) { + idRaw := strings.TrimSpace(c.Param("id")) + idU64, err := strconv.ParseUint(idRaw, 10, 64) + if err != nil || idU64 == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"}) + return + } + var req UpdateMasterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var m model.Master + if err := h.db.First(&m, uint(idU64)).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "master not found"}) + return + } + + update := make(map[string]any) + if req.Name != nil { + update["name"] = strings.TrimSpace(*req.Name) + } + if req.Group != nil { + g := strings.TrimSpace(*req.Group) + if g != "" { + update["group"] = g + } + } + if req.MaxChildKeys != nil && *req.MaxChildKeys > 0 { + update["max_child_keys"] = *req.MaxChildKeys + } + if req.GlobalQPS != nil && *req.GlobalQPS > 0 { + update["global_qps"] = *req.GlobalQPS + } + if len(update) == 0 && !req.PropagateToKeys { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + if len(update) > 0 { + if err := h.db.Model(&m).Updates(update).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update master", "details": err.Error()}) + return + } + } + if err := h.db.First(&m, uint(idU64)).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload master", "details": err.Error()}) + return + } + + if req.PropagateToKeys && req.Group != nil { + g := strings.TrimSpace(*req.Group) + if g != "" { + if err := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Updates(map[string]any{"group": g}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to propagate group to keys", "details": err.Error()}) + return + } + } + } + + // Sync master metadata and (if propagated) keys. + if err := h.syncService.SyncMaster(&m); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master", "details": err.Error()}) + return + } + if req.PropagateToKeys { + var keys []model.Key + if err := h.db.Where("master_id = ?", m.ID).Find(&keys).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload keys for sync", "details": err.Error()}) + return + } + for i := range keys { + if err := h.syncService.SyncKey(&keys[i]); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync key", "details": err.Error()}) + return + } + } + } + + c.JSON(http.StatusOK, toMasterView(m)) +} + +type ManageMasterRequest struct { + Action string `json:"action" binding:"required"` // freeze/unfreeze +} + +// ManageMaster godoc +// @Summary Manage master status +// @Description Freeze or unfreeze a master tenant +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminAuth +// @Param id path int true "Master ID" +// @Param request body ManageMasterRequest true "Action" +// @Success 200 {object} MasterView +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/masters/{id}/manage [post] +func (h *AdminHandler) ManageMaster(c *gin.Context) { + idRaw := strings.TrimSpace(c.Param("id")) + idU64, err := strconv.ParseUint(idRaw, 10, 64) + if err != nil || idU64 == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"}) + return + } + var req ManageMasterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + action := strings.ToLower(strings.TrimSpace(req.Action)) + var status string + switch action { + case "freeze": + status = "suspended" + case "unfreeze": + status = "active" + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid action"}) + return + } + + var m model.Master + if err := h.db.First(&m, uint(idU64)).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "master not found"}) + return + } + + if err := h.db.Model(&m).Update("status", status).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update master status", "details": err.Error()}) + return + } + if err := h.db.First(&m, uint(idU64)).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload master", "details": err.Error()}) + return + } + if err := h.syncService.SyncMaster(&m); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, toMasterView(m)) +} + +// DeleteMaster godoc +// @Summary Delete (revoke) master +// @Description Suspends a master and revokes all existing keys by bumping epoch and syncing to Redis +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Master ID" +// @Success 200 {object} gin.H +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/masters/{id} [delete] +func (h *AdminHandler) DeleteMaster(c *gin.Context) { + idRaw := strings.TrimSpace(c.Param("id")) + idU64, err := strconv.ParseUint(idRaw, 10, 64) + if err != nil || idU64 == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid master id"}) + return + } + + var m model.Master + if err := h.db.First(&m, uint(idU64)).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "master not found"}) + return + } + + nextEpoch := m.Epoch + 1 + if nextEpoch <= 0 { + nextEpoch = 1 + } + if err := h.db.Model(&m).Updates(map[string]any{ + "status": "suspended", + "epoch": nextEpoch, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke master", "details": err.Error()}) + return + } + if err := h.db.First(&m, uint(idU64)).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload master", "details": err.Error()}) + return + } + + // Revoke all child keys too (defense-in-depth; master status already blocks in DP). + if err := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Update("status", "suspended").Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke child keys", "details": err.Error()}) + return + } + + if err := h.syncService.SyncMaster(&m); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync master", "details": err.Error()}) + return + } + var keys []model.Key + if err := h.db.Where("master_id = ?", m.ID).Find(&keys).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload keys for sync", "details": err.Error()}) + return + } + for i := range keys { + if err := h.syncService.SyncKey(&keys[i]); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync key", "details": err.Error()}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"status": "revoked"}) +} + // IssueChildKeyForMaster godoc // @Summary Issue a child key on behalf of a master // @Description Issue a new access token (child key) for a specified master. The key still belongs to the master; issuer is recorded as admin for audit. diff --git a/internal/api/admin_issue_key_test.go b/internal/api/admin_issue_key_test.go index a3793af..2d0dfc9 100644 --- a/internal/api/admin_issue_key_test.go +++ b/internal/api/admin_issue_key_test.go @@ -34,7 +34,7 @@ func TestAdmin_IssueChildKeyForMaster_IssuedByAdminAndSynced(t *testing.T) { syncService := service.NewSyncService(rdb) masterService := service.NewMasterService(db) - adminHandler := NewAdminHandler(masterService, syncService) + adminHandler := NewAdminHandler(db, masterService, syncService) m, _, err := masterService.CreateMaster("m1", "default", 5, 10) if err != nil { diff --git a/internal/api/log_handler.go b/internal/api/log_handler.go new file mode 100644 index 0000000..2f0b949 --- /dev/null +++ b/internal/api/log_handler.go @@ -0,0 +1,348 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/ez-api/ez-api/internal/model" + "github.com/gin-gonic/gin" +) + +type LogView struct { + ID uint `json:"id"` + CreatedAt int64 `json:"created_at"` + Group string `json:"group"` + KeyID uint `json:"key_id"` + ModelName string `json:"model"` + StatusCode int `json:"status_code"` + LatencyMs int64 `json:"latency_ms"` + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` + ErrorMessage string `json:"error_message"` + ClientIP string `json:"client_ip"` + RequestSize int64 `json:"request_size"` + ResponseSize int64 `json:"response_size"` + AuditReason string `json:"audit_reason"` +} + +func toLogView(r model.LogRecord) LogView { + return LogView{ + ID: r.ID, + CreatedAt: r.CreatedAt.UTC().Unix(), + Group: r.Group, + KeyID: r.KeyID, + ModelName: r.ModelName, + StatusCode: r.StatusCode, + LatencyMs: r.LatencyMs, + TokensIn: r.TokensIn, + TokensOut: r.TokensOut, + ErrorMessage: r.ErrorMessage, + ClientIP: r.ClientIP, + RequestSize: r.RequestSize, + ResponseSize: r.ResponseSize, + AuditReason: r.AuditReason, + } +} + +type ListLogsResponse struct { + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Items []LogView `json:"items"` +} + +func parseLimitOffset(c *gin.Context) (limit, offset int) { + limit = 50 + offset = 0 + if raw := strings.TrimSpace(c.Query("limit")); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + limit = v + } + } + if limit > 200 { + limit = 200 + } + if raw := strings.TrimSpace(c.Query("offset")); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v >= 0 { + offset = v + } + } + return limit, offset +} + +func parseUnixSeconds(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + sec, err := strconv.ParseInt(raw, 10, 64) + if err != nil || sec <= 0 { + return time.Time{}, false + } + return time.Unix(sec, 0).UTC(), true +} + +// ListLogs godoc +// @Summary List logs (admin) +// @Description List request logs with basic filtering/pagination +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param limit query int false "limit (default 50, max 200)" +// @Param offset query int false "offset" +// @Param since query int false "unix seconds" +// @Param until query int false "unix seconds" +// @Param key_id query int false "key id" +// @Param group query string false "route group" +// @Param model query string false "model" +// @Param status_code query int false "status code" +// @Success 200 {object} ListLogsResponse +// @Failure 500 {object} gin.H +// @Router /admin/logs [get] +func (h *Handler) ListLogs(c *gin.Context) { + limit, offset := parseLimitOffset(c) + + q := h.db.Model(&model.LogRecord{}) + + if t, ok := parseUnixSeconds(c.Query("since")); ok { + q = q.Where("created_at >= ?", t) + } + if t, ok := parseUnixSeconds(c.Query("until")); ok { + q = q.Where("created_at <= ?", t) + } + if raw := strings.TrimSpace(c.Query("key_id")); raw != "" { + if v, err := strconv.ParseUint(raw, 10, 64); err == nil && v > 0 { + q = q.Where("key_id = ?", uint(v)) + } + } + if raw := strings.TrimSpace(c.Query("group")); raw != "" { + q = q.Where(`"group" = ?`, raw) + } + if raw := strings.TrimSpace(c.Query("model")); raw != "" { + q = q.Where("model_name = ?", raw) + } + if raw := strings.TrimSpace(c.Query("status_code")); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + q = q.Where("status_code = ?", v) + } + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()}) + return + } + + var rows []model.LogRecord + if err := q.Order("id desc").Limit(limit).Offset(offset).Find(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list logs", "details": err.Error()}) + return + } + out := make([]LogView, 0, len(rows)) + for _, r := range rows { + out = append(out, toLogView(r)) + } + c.JSON(http.StatusOK, ListLogsResponse{Total: total, Limit: limit, Offset: offset, Items: out}) +} + +type LogStatsResponse struct { + Total int64 `json:"total"` + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` + AvgLatency float64 `json:"avg_latency_ms"` + ByStatus map[string]int64 `json:"by_status"` +} + +// LogStats godoc +// @Summary Log stats (admin) +// @Description Aggregate log stats with basic filtering +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param since query int false "unix seconds" +// @Param until query int false "unix seconds" +// @Success 200 {object} LogStatsResponse +// @Failure 500 {object} gin.H +// @Router /admin/logs/stats [get] +func (h *Handler) LogStats(c *gin.Context) { + q := h.db.Model(&model.LogRecord{}) + if t, ok := parseUnixSeconds(c.Query("since")); ok { + q = q.Where("created_at >= ?", t) + } + if t, ok := parseUnixSeconds(c.Query("until")); ok { + q = q.Where("created_at <= ?", t) + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()}) + return + } + + type sums struct { + TokensIn int64 + TokensOut int64 + AvgLatency float64 + } + var s sums + if err := q.Select("COALESCE(SUM(tokens_in),0) as tokens_in, COALESCE(SUM(tokens_out),0) as tokens_out, COALESCE(AVG(latency_ms),0) as avg_latency").Scan(&s).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate logs", "details": err.Error()}) + return + } + + type bucket struct { + StatusCode int + Cnt int64 + } + var buckets []bucket + if err := q.Select("status_code, COUNT(*) as cnt").Group("status_code").Scan(&buckets).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bucket logs", "details": err.Error()}) + return + } + byStatus := make(map[string]int64, len(buckets)) + for _, b := range buckets { + byStatus[strconv.Itoa(b.StatusCode)] = b.Cnt + } + + c.JSON(http.StatusOK, LogStatsResponse{ + Total: total, + TokensIn: s.TokensIn, + TokensOut: s.TokensOut, + AvgLatency: s.AvgLatency, + ByStatus: byStatus, + }) +} + +// ListSelfLogs godoc +// @Summary List logs (master) +// @Description List request logs for the authenticated master +// @Tags master +// @Produce json +// @Security MasterAuth +// @Param limit query int false "limit (default 50, max 200)" +// @Param offset query int false "offset" +// @Param since query int false "unix seconds" +// @Param until query int false "unix seconds" +// @Param model query string false "model" +// @Param status_code query int false "status code" +// @Success 200 {object} ListLogsResponse +// @Failure 401 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /v1/logs [get] +func (h *MasterHandler) ListSelfLogs(c *gin.Context) { + master, exists := c.Get("master") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"}) + return + } + m := master.(*model.Master) + limit, offset := parseLimitOffset(c) + + q := h.db.Model(&model.LogRecord{}). + Joins("JOIN keys ON keys.id = log_records.key_id"). + Where("keys.master_id = ?", m.ID) + + if t, ok := parseUnixSeconds(c.Query("since")); ok { + q = q.Where("log_records.created_at >= ?", t) + } + if t, ok := parseUnixSeconds(c.Query("until")); ok { + q = q.Where("log_records.created_at <= ?", t) + } + if raw := strings.TrimSpace(c.Query("model")); raw != "" { + q = q.Where("log_records.model_name = ?", raw) + } + if raw := strings.TrimSpace(c.Query("status_code")); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + q = q.Where("log_records.status_code = ?", v) + } + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()}) + return + } + var rows []model.LogRecord + if err := q.Order("log_records.id desc").Limit(limit).Offset(offset).Find(&rows).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list logs", "details": err.Error()}) + return + } + out := make([]LogView, 0, len(rows)) + for _, r := range rows { + out = append(out, toLogView(r)) + } + c.JSON(http.StatusOK, ListLogsResponse{Total: total, Limit: limit, Offset: offset, Items: out}) +} + +// GetSelfStats godoc +// @Summary Log stats (master) +// @Description Aggregate request log stats for the authenticated master +// @Tags master +// @Produce json +// @Security MasterAuth +// @Param since query int false "unix seconds" +// @Param until query int false "unix seconds" +// @Success 200 {object} LogStatsResponse +// @Failure 401 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /v1/stats [get] +func (h *MasterHandler) GetSelfStats(c *gin.Context) { + master, exists := c.Get("master") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"}) + return + } + m := master.(*model.Master) + + q := h.db.Model(&model.LogRecord{}). + Joins("JOIN keys ON keys.id = log_records.key_id"). + Where("keys.master_id = ?", m.ID) + + if t, ok := parseUnixSeconds(c.Query("since")); ok { + q = q.Where("log_records.created_at >= ?", t) + } + if t, ok := parseUnixSeconds(c.Query("until")); ok { + q = q.Where("log_records.created_at <= ?", t) + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count logs", "details": err.Error()}) + return + } + + type sums struct { + TokensIn int64 + TokensOut int64 + AvgLatency float64 + } + var s sums + if err := q.Select("COALESCE(SUM(log_records.tokens_in),0) as tokens_in, COALESCE(SUM(log_records.tokens_out),0) as tokens_out, COALESCE(AVG(log_records.latency_ms),0) as avg_latency").Scan(&s).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate logs", "details": err.Error()}) + return + } + + type bucket struct { + StatusCode int + Cnt int64 + } + var buckets []bucket + if err := q.Select("log_records.status_code as status_code, COUNT(*) as cnt").Group("log_records.status_code").Scan(&buckets).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bucket logs", "details": err.Error()}) + return + } + byStatus := make(map[string]int64, len(buckets)) + for _, b := range buckets { + byStatus[strconv.Itoa(b.StatusCode)] = b.Cnt + } + + c.JSON(http.StatusOK, LogStatsResponse{ + Total: total, + TokensIn: s.TokensIn, + TokensOut: s.TokensOut, + AvgLatency: s.AvgLatency, + ByStatus: byStatus, + }) +} diff --git a/internal/api/log_handler_test.go b/internal/api/log_handler_test.go new file mode 100644 index 0000000..900b26b --- /dev/null +++ b/internal/api/log_handler_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ez-api/ez-api/internal/model" + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestMaster_ListSelfLogs_FiltersByMaster(t *testing.T) { + gin.SetMode(gin.TestMode) + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.LogRecord{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + m1 := &model.Master{Name: "m1", Group: "g", Status: "active", Epoch: 1, MasterKeyDigest: "d1"} + m2 := &model.Master{Name: "m2", Group: "g", Status: "active", Epoch: 1, MasterKeyDigest: "d2"} + if err := db.Create(m1).Error; err != nil { + t.Fatalf("create m1: %v", err) + } + if err := db.Create(m2).Error; err != nil { + t.Fatalf("create m2: %v", err) + } + k1 := &model.Key{MasterID: m1.ID, TokenHash: "h1", Group: "g", Status: "active", IssuedAtEpoch: 1} + k2 := &model.Key{MasterID: m2.ID, TokenHash: "h2", Group: "g", Status: "active", IssuedAtEpoch: 1} + if err := db.Create(k1).Error; err != nil { + t.Fatalf("create k1: %v", err) + } + if err := db.Create(k2).Error; err != nil { + t.Fatalf("create k2: %v", err) + } + + if err := db.Create(&model.LogRecord{Group: "rg", KeyID: k1.ID, ModelName: "ns.m", StatusCode: 200, LatencyMs: 10}).Error; err != nil { + t.Fatalf("create log1: %v", err) + } + if err := db.Create(&model.LogRecord{Group: "rg", KeyID: k2.ID, ModelName: "ns.m", StatusCode: 400, LatencyMs: 20}).Error; err != nil { + t.Fatalf("create log2: %v", err) + } + + mh := &MasterHandler{db: db} + + withMaster := func(next gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("master", m1) + next(c) + } + } + + r := gin.New() + r.GET("/v1/logs", withMaster(mh.ListSelfLogs)) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/logs", nil) + r.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + var resp ListLogsResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Total != 1 || len(resp.Items) != 1 { + t.Fatalf("expected 1 item, got total=%d len=%d body=%s", resp.Total, len(resp.Items), rr.Body.String()) + } + if resp.Items[0].KeyID != k1.ID { + t.Fatalf("expected key_id %d, got %+v", k1.ID, resp.Items[0]) + } +} diff --git a/internal/api/master_handler.go b/internal/api/master_handler.go index 7ad6240..1149df5 100644 --- a/internal/api/master_handler.go +++ b/internal/api/master_handler.go @@ -3,20 +3,23 @@ package api import ( "errors" "net/http" + "strconv" "strings" "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 MasterHandler struct { + db *gorm.DB masterService *service.MasterService syncService *service.SyncService } -func NewMasterHandler(masterService *service.MasterService, syncService *service.SyncService) *MasterHandler { - return &MasterHandler{masterService: masterService, syncService: syncService} +func NewMasterHandler(db *gorm.DB, masterService *service.MasterService, syncService *service.SyncService) *MasterHandler { + return &MasterHandler{db: db, masterService: masterService, syncService: syncService} } type IssueChildKeyRequest struct { @@ -94,3 +97,243 @@ func (h *MasterHandler) IssueChildKey(c *gin.Context) { "scopes": key.Scopes, }) } + +// GetSelf godoc +// @Summary Get current master info +// @Description Returns master metadata for the authenticated master key +// @Tags master +// @Produce json +// @Security MasterAuth +// @Success 200 {object} MasterView +// @Failure 401 {object} gin.H +// @Router /v1/self [get] +func (h *MasterHandler) GetSelf(c *gin.Context) { + master, exists := c.Get("master") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"}) + return + } + m := master.(*model.Master) + c.JSON(http.StatusOK, toMasterView(*m)) +} + +type TokenView struct { + ID uint `json:"id"` + Group string `json:"group"` + Scopes string `json:"scopes"` + Status string `json:"status"` + IssuedBy string `json:"issued_by"` + IssuedAtEpoch int64 `json:"issued_at_epoch"` + DefaultNamespace string `json:"default_namespace"` + Namespaces string `json:"namespaces"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func toTokenView(k model.Key) TokenView { + return TokenView{ + ID: k.ID, + Group: k.Group, + Scopes: k.Scopes, + Status: k.Status, + IssuedBy: k.IssuedBy, + IssuedAtEpoch: k.IssuedAtEpoch, + DefaultNamespace: strings.TrimSpace(k.DefaultNamespace), + Namespaces: strings.TrimSpace(k.Namespaces), + CreatedAt: k.CreatedAt.UTC().Unix(), + UpdatedAt: k.UpdatedAt.UTC().Unix(), + } +} + +// ListTokens godoc +// @Summary List child keys +// @Description List child keys issued under the authenticated master +// @Tags master +// @Produce json +// @Security MasterAuth +// @Success 200 {array} TokenView +// @Failure 401 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /v1/tokens [get] +func (h *MasterHandler) ListTokens(c *gin.Context) { + master, exists := c.Get("master") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"}) + return + } + m := master.(*model.Master) + + var keys []model.Key + if err := h.db.Where("master_id = ?", m.ID).Order("id desc").Find(&keys).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens", "details": err.Error()}) + return + } + out := make([]TokenView, 0, len(keys)) + for _, k := range keys { + out = append(out, toTokenView(k)) + } + c.JSON(http.StatusOK, out) +} + +// GetToken godoc +// @Summary Get child key +// @Description Get a child key by id under the authenticated master +// @Tags master +// @Produce json +// @Security MasterAuth +// @Param id path int true "Token ID" +// @Success 200 {object} TokenView +// @Failure 400 {object} gin.H +// @Failure 401 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /v1/tokens/{id} [get] +func (h *MasterHandler) GetToken(c *gin.Context) { + master, exists := c.Get("master") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"}) + return + } + m := master.(*model.Master) + + idRaw := strings.TrimSpace(c.Param("id")) + idU64, err := strconv.ParseUint(idRaw, 10, 64) + if err != nil || idU64 == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id"}) + return + } + + var k model.Key + if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "token not found"}) + return + } + c.JSON(http.StatusOK, toTokenView(k)) +} + +type UpdateTokenRequest struct { + Scopes *string `json:"scopes,omitempty"` + Status *string `json:"status,omitempty"` // active/suspended +} + +// UpdateToken godoc +// @Summary Update child key +// @Description Update token scopes/status under the authenticated master +// @Tags master +// @Accept json +// @Produce json +// @Security MasterAuth +// @Param id path int true "Token ID" +// @Param request body UpdateTokenRequest true "Update payload" +// @Success 200 {object} TokenView +// @Failure 400 {object} gin.H +// @Failure 401 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /v1/tokens/{id} [put] +func (h *MasterHandler) UpdateToken(c *gin.Context) { + master, exists := c.Get("master") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"}) + return + } + m := master.(*model.Master) + + idRaw := strings.TrimSpace(c.Param("id")) + idU64, err := strconv.ParseUint(idRaw, 10, 64) + if err != nil || idU64 == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id"}) + return + } + + var req UpdateTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + update := make(map[string]any) + if req.Scopes != nil { + update["scopes"] = strings.TrimSpace(*req.Scopes) + } + if req.Status != nil { + st := strings.ToLower(strings.TrimSpace(*req.Status)) + if st != "active" && st != "suspended" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) + return + } + update["status"] = st + } + if len(update) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + var k model.Key + if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "token not found"}) + return + } + if err := h.db.Model(&k).Updates(update).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update token", "details": err.Error()}) + return + } + if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload token", "details": err.Error()}) + return + } + if err := h.syncService.SyncKey(&k); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync token", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, toTokenView(k)) +} + +// DeleteToken godoc +// @Summary Delete (revoke) child key +// @Description Suspends a child key under the authenticated master +// @Tags master +// @Produce json +// @Security MasterAuth +// @Param id path int true "Token ID" +// @Success 200 {object} gin.H +// @Failure 400 {object} gin.H +// @Failure 401 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /v1/tokens/{id} [delete] +func (h *MasterHandler) DeleteToken(c *gin.Context) { + master, exists := c.Get("master") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"}) + return + } + m := master.(*model.Master) + + idRaw := strings.TrimSpace(c.Param("id")) + idU64, err := strconv.ParseUint(idRaw, 10, 64) + if err != nil || idU64 == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id"}) + return + } + + var k model.Key + if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "token not found"}) + return + } + + if err := h.db.Model(&k).Update("status", "suspended").Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke token", "details": err.Error()}) + return + } + if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload token", "details": err.Error()}) + return + } + if err := h.syncService.SyncKey(&k); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync token", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "revoked"}) +} diff --git a/internal/api/master_tokens_handler_test.go b/internal/api/master_tokens_handler_test.go new file mode 100644 index 0000000..a8e6b78 --- /dev/null +++ b/internal/api/master_tokens_handler_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/ez-api/ez-api/internal/model" + "github.com/ez-api/ez-api/internal/service" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestMaster_ListTokens_AndUpdateToken(t *testing.T) { + gin.SetMode(gin.TestMode) + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.LogRecord{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + m := &model.Master{Name: "m1", Group: "g", Status: "active", Epoch: 1, DefaultNamespace: "ns", Namespaces: "ns", MaxChildKeys: 5, GlobalQPS: 3} + if err := db.Create(m).Error; err != nil { + t.Fatalf("create master: %v", err) + } + k := &model.Key{MasterID: m.ID, TokenHash: "h", Group: "g", Status: "active", Scopes: "chat:write", IssuedAtEpoch: 1, DefaultNamespace: "ns", Namespaces: "ns"} + if err := db.Create(k).Error; err != nil { + t.Fatalf("create key: %v", err) + } + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + syncSvc := service.NewSyncService(rdb) + masterSvc := service.NewMasterService(db) + h := NewMasterHandler(db, masterSvc, syncSvc) + + withMaster := func(next gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("master", m) + next(c) + } + } + + r := gin.New() + r.GET("/v1/tokens", withMaster(h.ListTokens)) + r.PUT("/v1/tokens/:id", withMaster(h.UpdateToken)) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/tokens", nil) + r.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + var list []TokenView + if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil { + t.Fatalf("unmarshal list: %v", err) + } + if len(list) != 1 || list[0].ID != k.ID { + t.Fatalf("unexpected list: %+v", list) + } + + body := []byte(`{"scopes":"chat:write,chat:read","status":"suspended"}`) + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/v1/tokens/%d", k.ID), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + var updated TokenView + if err := json.Unmarshal(rr.Body.Bytes(), &updated); err != nil { + t.Fatalf("unmarshal updated: %v", err) + } + if updated.Status != "suspended" { + t.Fatalf("expected status suspended, got %+v", updated) + } + if mr.HGet("auth:token:h", "status") != "suspended" { + t.Fatalf("expected redis token status suspended, got %q", mr.HGet("auth:token:h", "status")) + } +} diff --git a/internal/api/provider_admin_handler.go b/internal/api/provider_admin_handler.go new file mode 100644 index 0000000..8286771 --- /dev/null +++ b/internal/api/provider_admin_handler.go @@ -0,0 +1,182 @@ +package api + +import ( + "io" + "net/http" + "strings" + "time" + + "github.com/ez-api/ez-api/internal/model" + "github.com/ez-api/foundation/provider" + "github.com/gin-gonic/gin" +) + +// ListProviders godoc +// @Summary List providers +// @Description List all configured upstream providers +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Success 200 {array} model.Provider +// @Failure 500 {object} gin.H +// @Router /admin/providers [get] +func (h *Handler) ListProviders(c *gin.Context) { + var providers []model.Provider + if err := h.db.Order("id desc").Find(&providers).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, providers) +} + +// GetProvider godoc +// @Summary Get provider +// @Description Get a provider by id +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Provider ID" +// @Success 200 {object} model.Provider +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/providers/{id} [get] +func (h *Handler) GetProvider(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + var p model.Provider + if err := h.db.First(&p, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + c.JSON(http.StatusOK, p) +} + +// DeleteProvider godoc +// @Summary Delete provider +// @Description Deletes a provider and triggers a full snapshot sync to avoid stale routing +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Provider ID" +// @Success 200 {object} gin.H +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/providers/{id} [delete] +func (h *Handler) DeleteProvider(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + + var p model.Provider + if err := h.db.First(&p, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + + if err := h.db.Delete(&p).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider", "details": err.Error()}) + return + } + + // Full sync is the simplest safe option because provider deletion needs to remove + // stale entries in Redis snapshots (config:providers) and refresh bindings. + 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": "deleted"}) +} + +type testProviderResponse struct { + StatusCode int `json:"status_code"` + OK bool `json:"ok"` + URL string `json:"url"` + Body string `json:"body,omitempty"` +} + +// TestProvider godoc +// @Summary Test provider connectivity +// @Description Performs a lightweight upstream request to verify the provider configuration +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Provider ID" +// @Success 200 {object} testProviderResponse +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 500 {object} gin.H +// @Router /admin/providers/{id}/test [post] +func (h *Handler) TestProvider(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + var p model.Provider + if err := h.db.First(&p, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + + pt := provider.NormalizeType(p.Type) + baseURL := strings.TrimRight(strings.TrimSpace(p.BaseURL), "/") + if baseURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "base_url required for provider test"}) + return + } + + url := "" + var req *http.Request + + switch pt { + case provider.TypeOpenAI, provider.TypeCompatible: + url = baseURL + "/models" + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build request", "details": err.Error()}) + return + } + req = r + if strings.TrimSpace(p.APIKey) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(p.APIKey)) + } + case provider.TypeAnthropic, provider.TypeClaude: + url = baseURL + "/v1/models" + r, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build request", "details": err.Error()}) + return + } + req = r + if strings.TrimSpace(p.APIKey) != "" { + req.Header.Set("x-api-key", strings.TrimSpace(p.APIKey)) + } + req.Header.Set("anthropic-version", "2023-06-01") + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "provider type not supported for test"}) + return + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusOK, testProviderResponse{StatusCode: 0, OK: false, URL: url, Body: err.Error()}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + ok = resp.StatusCode >= 200 && resp.StatusCode < 300 + + c.JSON(http.StatusOK, testProviderResponse{ + StatusCode: resp.StatusCode, + OK: ok, + URL: url, + Body: string(body), + }) +} diff --git a/internal/api/provider_admin_handler_test.go b/internal/api/provider_admin_handler_test.go new file mode 100644 index 0000000..2a4eda7 --- /dev/null +++ b/internal/api/provider_admin_handler_test.go @@ -0,0 +1,60 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ez-api/ez-api/internal/model" + "github.com/gin-gonic/gin" +) + +func TestAdmin_TestProvider_OpenAICompatible(t *testing.T) { + h, db := newTestHandler(t) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + http.NotFound(w, r) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer k" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[]}`)) + })) + defer upstream.Close() + + p := &model.Provider{ + Name: "p1", + Type: "openai", + BaseURL: upstream.URL + "/v1", + APIKey: "k", + Group: "default", + Models: "gpt-4o-mini", + Status: "active", + } + if err := db.Create(p).Error; err != nil { + t.Fatalf("create provider: %v", err) + } + + r := gin.New() + r.POST("/admin/providers/:id/test", h.TestProvider) + + req := httptest.NewRequest(http.MethodPost, "/admin/providers/1/test", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ok, _ := payload["ok"].(bool); !ok { + t.Fatalf("expected ok=true, got %v body=%s", payload["ok"], rr.Body.String()) + } +} diff --git a/internal/api/provider_handler_test.go b/internal/api/provider_handler_test.go index 3cfea47..163b8eb 100644 --- a/internal/api/provider_handler_test.go +++ b/internal/api/provider_handler_test.go @@ -28,7 +28,7 @@ func newTestHandler(t *testing.T) (*Handler, *gorm.DB) { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := db.AutoMigrate(&model.Provider{}, &model.Binding{}, &model.Model{}); err != nil { + if err := db.AutoMigrate(&model.Master{}, &model.Key{}, &model.Provider{}, &model.Model{}, &model.Binding{}, &model.LogRecord{}); err != nil { t.Fatalf("migrate: %v", err) } diff --git a/internal/service/sync.go b/internal/service/sync.go index 976d189..6c9d4a9 100644 --- a/internal/service/sync.go +++ b/internal/service/sync.go @@ -36,6 +36,7 @@ func (s *SyncService) SyncKey(key *model.Key) error { } fields := map[string]interface{}{ + "id": key.ID, "master_id": key.MasterID, "issued_at_epoch": key.IssuedAtEpoch, "status": key.Status, @@ -260,6 +261,7 @@ func (s *SyncService) SyncAll(db *gorm.DB) error { return fmt.Errorf("token hash missing for key %d", k.ID) } pipe.HSet(ctx, fmt.Sprintf("auth:token:%s", tokenHash), map[string]interface{}{ + "id": k.ID, "master_id": k.MasterID, "issued_at_epoch": k.IssuedAtEpoch, "status": k.Status, diff --git a/internal/service/sync_test.go b/internal/service/sync_test.go index 06b5e3b..b4f29c1 100644 --- a/internal/service/sync_test.go +++ b/internal/service/sync_test.go @@ -66,3 +66,29 @@ func TestSyncProvider_WritesSnapshotAndRouting(t *testing.T) { t.Fatalf("expected provider id 42 in routing set %q", routeKey) } } + +func TestSyncKey_WritesTokenID(t *testing.T) { + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + svc := NewSyncService(rdb) + + k := &model.Key{ + TokenHash: "hash", + MasterID: 1, + IssuedAtEpoch: 1, + Status: "active", + Group: "default", + Scopes: "chat:write", + DefaultNamespace: "default", + Namespaces: "default", + } + k.ID = 123 + + if err := svc.SyncKey(k); err != nil { + t.Fatalf("SyncKey: %v", err) + } + + if got := mr.HGet("auth:token:hash", "id"); got != "123" { + t.Fatalf("expected auth:token:hash.id=123, got %q", got) + } +}