package admin import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "strconv" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/handler/dto" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/platform/liveattestation" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) // GroupHandler handles admin group management type GroupHandler struct { adminService service.AdminService dashboardService *service.DashboardService groupCapacityService *service.GroupCapacityService } // GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。 func (h *GroupHandler) GetLiveCapability(c *gin.Context) { err := liveattestation.NewProvider().Check(c.Request.Context()) result := gin.H{"supported": err == nil} if err != nil { result["reason"] = err.Error() } response.Success(c, result) } type optionalLimitField struct { set bool value *float64 } func (f *optionalLimitField) UnmarshalJSON(data []byte) error { f.set = true trimmed := bytes.TrimSpace(data) if bytes.Equal(trimmed, []byte("null")) { f.value = nil return nil } var number float64 if err := json.Unmarshal(trimmed, &number); err == nil { f.value = &number return nil } var text string if err := json.Unmarshal(trimmed, &text); err == nil { text = strings.TrimSpace(text) if text == "" { f.value = nil return nil } number, err = strconv.ParseFloat(text, 64) if err != nil { return fmt.Errorf("invalid numeric limit value %q: %w", text, err) } f.value = &number return nil } return fmt.Errorf("invalid limit value: %s", string(trimmed)) } func (f optionalLimitField) ToServiceInput() *float64 { if !f.set { return nil } if f.value != nil { return f.value } zero := 0.0 return &zero } // NewGroupHandler creates a new admin group handler func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler { return &GroupHandler{ adminService: adminService, dashboardService: dashboardService, groupCapacityService: groupCapacityService, } } // CreateGroupRequest represents create group request type CreateGroupRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"` RateMultiplier float64 `json:"rate_multiplier"` IsExclusive bool `json:"is_exclusive"` SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` ModelPricing []service.ChannelModelPricing `json:"model_pricing"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) AllowImageGeneration bool `json:"allow_image_generation"` AllowBatchImageGeneration bool `json:"allow_batch_image_generation"` ImageRateIndependent bool `json:"image_rate_independent"` ImageRateMultiplier *float64 `json:"image_rate_multiplier"` BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"` BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"` VideoRateIndependent bool `json:"video_rate_independent"` VideoRateMultiplier *float64 `json:"video_rate_multiplier"` PeakRateEnabled bool `json:"peak_rate_enabled"` PeakStart string `json:"peak_start"` PeakEnd string `json:"peak_end"` PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` ProfitControlEnabled bool `json:"profit_control_enabled"` ProfitMinMargin *float64 `json:"profit_min_margin"` ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"` ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` VideoPrice480P *float64 `json:"video_price_480p"` VideoPrice720P *float64 `json:"video_price_720p"` VideoPrice1080P *float64 `json:"video_price_1080p"` VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` SearchPricePer1k *float64 `json:"search_price_per_1k"` AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"` AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"` AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"` ClaudeCodeOnly bool `json:"claude_code_only"` FallbackGroupID *int64 `json:"fallback_group_id"` FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 `json:"model_routing"` ModelRoutingEnabled bool `json:"model_routing_enabled"` MCPXMLInject *bool `json:"mcp_xml_inject"` // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string `json:"supported_model_scopes"` // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch bool `json:"allow_messages_dispatch"` AllowLive bool `json:"allow_live"` RequireOAuthOnly bool `json:"require_oauth_only"` RequirePrivacySet bool `json:"require_privacy_set"` DefaultMappedModel string `json:"default_mapped_model"` MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"` // 分组 RPM 上限(0 = 不限制) RPMLimit int `json:"rpm_limit"` // OpenAI/Codex 请求推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort"` // OpenAI/Codex 推理强度精确映射。 ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"` // 从指定分组复制账号(创建后自动绑定) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` } // UpdateGroupRequest represents update group request type UpdateGroupRequest struct { Name string `json:"name"` Description *string `json:"description"` Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"` RateMultiplier *float64 `json:"rate_multiplier"` IsExclusive *bool `json:"is_exclusive"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` LongContextPricingEnabled *bool `json:"long_context_pricing_enabled"` ModelPricing *[]service.ChannelModelPricing `json:"model_pricing"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) AllowImageGeneration *bool `json:"allow_image_generation"` AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"` ImageRateIndependent *bool `json:"image_rate_independent"` ImageRateMultiplier *float64 `json:"image_rate_multiplier"` BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"` BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"` VideoRateIndependent *bool `json:"video_rate_independent"` VideoRateMultiplier *float64 `json:"video_rate_multiplier"` PeakRateEnabled *bool `json:"peak_rate_enabled"` PeakStart *string `json:"peak_start"` PeakEnd *string `json:"peak_end"` PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` ProfitControlEnabled *bool `json:"profit_control_enabled"` ProfitMinMargin *float64 `json:"profit_min_margin"` ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"` ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` VideoPrice480P *float64 `json:"video_price_480p"` VideoPrice720P *float64 `json:"video_price_720p"` VideoPrice1080P *float64 `json:"video_price_1080p"` VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` SearchPricePer1k *float64 `json:"search_price_per_1k"` AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"` AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"` AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"` ClaudeCodeOnly *bool `json:"claude_code_only"` FallbackGroupID *int64 `json:"fallback_group_id"` FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 `json:"model_routing"` ModelRoutingEnabled *bool `json:"model_routing_enabled"` MCPXMLInject *bool `json:"mcp_xml_inject"` // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes *[]string `json:"supported_model_scopes"` // OpenAI Messages 调度配置(仅 openai 平台使用) AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` AllowLive *bool `json:"allow_live"` RequireOAuthOnly *bool `json:"require_oauth_only"` RequirePrivacySet *bool `json:"require_privacy_set"` DefaultMappedModel *string `json:"default_mapped_model"` MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"` // 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动 RPMLimit *int `json:"rpm_limit"` // OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。 MaxReasoningEffort *string `json:"max_reasoning_effort"` // nil 不修改,空数组清空,非空数组替换。 ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"` // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` } type CompositeRouteRequest struct { PublicModel string `json:"public_model" binding:"required"` MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"` TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"` UpstreamModel string `json:"upstream_model"` Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"` Priority int `json:"priority"` Enabled *bool `json:"enabled"` Notes string `json:"notes"` } type CompositeRoutePreviewRequest struct { Model string `json:"model" binding:"required"` Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"` } // List handles listing all groups with pagination // GET /api/v1/admin/groups func (h *GroupHandler) List(c *gin.Context) { page, pageSize := response.ParsePagination(c) platform := c.Query("platform") status := c.Query("status") search := c.Query("search") // 标准化和验证 search 参数 search = strings.TrimSpace(search) if len(search) > 100 { search = search[:100] } isExclusiveStr := c.Query("is_exclusive") sortBy := c.DefaultQuery("sort_by", "sort_order") sortOrder := c.DefaultQuery("sort_order", "asc") var isExclusive *bool if isExclusiveStr != "" { val := isExclusiveStr == "true" isExclusive = &val } groups, total, err := h.adminService.ListGroups(c.Request.Context(), page, pageSize, platform, status, search, isExclusive, sortBy, sortOrder) if err != nil { response.ErrorFrom(c, err) return } outGroups := make([]dto.AdminGroup, 0, len(groups)) for i := range groups { outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) } response.Paginated(c, outGroups, total, page, pageSize) } // ListCompositeRoutes handles listing composite model routes for one group. // GET /api/v1/admin/groups/:id/composite-routes func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) { groupID, ok := parsePositiveIDParam(c, "id") if !ok { return } routes, err := h.adminService.ListCompositeRoutes(c.Request.Context(), groupID) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, routes) } // CreateCompositeRoute handles creating one composite model route. // POST /api/v1/admin/groups/:id/composite-routes func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) { groupID, ok := parsePositiveIDParam(c, "id") if !ok { return } var req CompositeRouteRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request body: "+err.Error()) return } route, err := h.adminService.CreateCompositeRoute(c.Request.Context(), groupID, compositeRouteRequestToInput(req, true)) if err != nil { response.ErrorFrom(c, err) return } response.Created(c, route) } // UpdateCompositeRoute handles replacing one composite model route. // PUT /api/v1/admin/groups/:id/composite-routes/:route_id func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) { groupID, ok := parsePositiveIDParam(c, "id") if !ok { return } routeID, ok := parsePositiveIDParam(c, "route_id") if !ok { return } var req CompositeRouteRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request body: "+err.Error()) return } route, err := h.adminService.UpdateCompositeRoute(c.Request.Context(), groupID, routeID, compositeRouteRequestToInput(req, true)) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, route) } // DeleteCompositeRoute handles deleting one composite model route. // DELETE /api/v1/admin/groups/:id/composite-routes/:route_id func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) { groupID, ok := parsePositiveIDParam(c, "id") if !ok { return } routeID, ok := parsePositiveIDParam(c, "route_id") if !ok { return } if err := h.adminService.DeleteCompositeRoute(c.Request.Context(), groupID, routeID); err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"message": "Composite route deleted"}) } // PreviewCompositeRoute resolves a model without mutating routes. // POST /api/v1/admin/groups/:id/composite-routes/preview func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) { groupID, ok := parsePositiveIDParam(c, "id") if !ok { return } var req CompositeRoutePreviewRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request body: "+err.Error()) return } decision, err := h.adminService.PreviewCompositeRoute(c.Request.Context(), groupID, service.CompositeRoutePreviewRequest{ Model: req.Model, Endpoint: req.Endpoint, }) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, decision) } func compositeRouteRequestToInput(req CompositeRouteRequest, defaultEnabled bool) service.CompositeRouteInput { enabled := defaultEnabled if req.Enabled != nil { enabled = *req.Enabled } return service.CompositeRouteInput{ PublicModel: req.PublicModel, MatchType: req.MatchType, TargetPlatform: req.TargetPlatform, UpstreamModel: req.UpstreamModel, Endpoint: req.Endpoint, Priority: req.Priority, Enabled: enabled, Notes: req.Notes, } } func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) { raw := c.Param(name) id, err := strconv.ParseInt(raw, 10, 64) if err != nil || id <= 0 { response.BadRequest(c, "Invalid "+name) return 0, false } return id, true } // GetAll handles getting all active groups without pagination. // Pass ?include_inactive=true to also include disabled groups (used by the // API Key group filter, which needs to surface groups that still have API keys // bound to them even after the group is disabled). // GET /api/v1/admin/groups/all func (h *GroupHandler) GetAll(c *gin.Context) { platform := c.Query("platform") includeInactive := c.Query("include_inactive") == "true" var groups []service.Group var err error if includeInactive { groups, err = h.adminService.GetAllGroupsIncludingInactive(c.Request.Context()) } else if platform != "" { groups, err = h.adminService.GetAllGroupsByPlatform(c.Request.Context(), platform) } else { groups, err = h.adminService.GetAllGroups(c.Request.Context()) } if err != nil { response.ErrorFrom(c, err) return } outGroups := make([]dto.AdminGroup, 0, len(groups)) for i := range groups { outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) } response.Success(c, outGroups) } // GetByID handles getting a group by ID // GET /api/v1/admin/groups/:id func (h *GroupHandler) GetByID(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } group, err := h.adminService.GetGroup(c.Request.Context(), groupID) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, dto.GroupFromServiceAdmin(group)) } // GetModelsListCandidates handles getting candidate model IDs for custom /v1/models list. // GET /api/v1/admin/groups/:id/models-list-candidates func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || groupID < 0 { response.BadRequest(c, "Invalid group ID") return } models, err := h.adminService.GetGroupModelsListCandidates( c.Request.Context(), groupID, c.Query("platform"), ) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"models": models}) } // Create handles creating a new group // POST /api/v1/admin/groups func (h *GroupHandler) Create(c *gin.Context) { var req CreateGroupRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil { response.BadRequest(c, err.Error()) return } // platform 是 omitempty:预校验必须用与 CreateGroup 落库一致的归一化平台, // 否则省略 platform 的请求会被误判成「平台不支持利润控制」。 if err := service.ValidateProfitControlConfig(service.NormalizeGroupPlatform(req.Platform), req.ProfitControlEnabled, float64ValueOrDefault(req.ProfitMinMargin, 0), float64ValueOrDefault(req.ProfitSafetyBuffer, 0)); err != nil { response.BadRequest(c, err.Error()) return } group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{ Name: req.Name, Description: req.Description, Platform: req.Platform, RateMultiplier: req.RateMultiplier, IsExclusive: req.IsExclusive, SubscriptionType: req.SubscriptionType, DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(), MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(), LongContextPricingEnabled: req.LongContextPricingEnabled, ModelPricing: req.ModelPricing, AllowImageGeneration: req.AllowImageGeneration, AllowBatchImageGeneration: req.AllowBatchImageGeneration, ImageRateIndependent: req.ImageRateIndependent, ImageRateMultiplier: req.ImageRateMultiplier, BatchImageDiscountMultiplier: req.BatchImageDiscountMultiplier, BatchImageHoldMultiplier: req.BatchImageHoldMultiplier, VideoRateIndependent: req.VideoRateIndependent, VideoRateMultiplier: req.VideoRateMultiplier, PeakRateEnabled: req.PeakRateEnabled, PeakStart: req.PeakStart, PeakEnd: req.PeakEnd, PeakRateMultiplier: req.PeakRateMultiplier, ProfitControlEnabled: req.ProfitControlEnabled, ProfitMinMargin: req.ProfitMinMargin, ProfitSafetyBuffer: req.ProfitSafetyBuffer, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, VideoPrice480P: req.VideoPrice480P, VideoPrice720P: req.VideoPrice720P, VideoPrice1080P: req.VideoPrice1080P, VideoModelPrices: req.VideoModelPrices, WebSearchPricePerCall: req.WebSearchPricePerCall, SearchPricePer1k: req.SearchPricePer1k, AudioRealtimePricePerMin: req.AudioRealtimePricePerMin, AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars, AudioSTTPricePerHour: req.AudioSttPricePerHour, ClaudeCodeOnly: req.ClaudeCodeOnly, FallbackGroupID: req.FallbackGroupID, FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest, ModelRouting: req.ModelRouting, ModelRoutingEnabled: req.ModelRoutingEnabled, MCPXMLInject: req.MCPXMLInject, SupportedModelScopes: req.SupportedModelScopes, AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, MessagesDispatchModelConfig: req.MessagesDispatchModelConfig, ModelsListConfig: req.ModelsListConfig, RPMLimit: req.RPMLimit, MaxReasoningEffort: req.MaxReasoningEffort, ReasoningEffortMappings: req.ReasoningEffortMappings, CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, dto.GroupFromServiceAdmin(group)) } // Duplicate handles creating an inactive group copy with the source account bindings. // POST /api/v1/admin/groups/:id/duplicate func (h *GroupHandler) Duplicate(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || groupID <= 0 { response.BadRequest(c, "Invalid group ID") return } actorScope := adminActorScope(c) result, err := executeAdminIdempotent( c, "admin.groups.duplicate", struct { GroupID int64 `json:"group_id"` }{GroupID: groupID}, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { group, execErr := h.adminService.DuplicateGroup(ctx, groupID, actorScope, c.GetHeader("Idempotency-Key")) if execErr != nil { return nil, execErr } return dto.GroupFromServiceAdmin(group), nil }, ) if err != nil { reason := infraerrors.Reason(err) if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) { recovered, recoverErr := h.adminService.RecoverDuplicateGroup(c.Request.Context(), groupID, actorScope, c.GetHeader("Idempotency-Key")) if recoverErr != nil { slog.Warn("group_duplicate_recovery_failed", "group_id", groupID, "actor_scope", actorScope, "reason", reason, "error", recoverErr) } else if recovered != nil { c.Header("X-Idempotency-Recovered", "true") response.Success(c, dto.GroupFromServiceAdmin(recovered)) return } } response.ErrorFrom(c, err) return } if result != nil && result.Replayed { c.Header("X-Idempotency-Replayed", "true") } response.Success(c, result.Data) } // Update handles updating a group // PUT /api/v1/admin/groups/:id func (h *GroupHandler) Update(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } var req UpdateGroupRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{ Name: req.Name, Description: req.Description, Platform: req.Platform, RateMultiplier: req.RateMultiplier, IsExclusive: req.IsExclusive, Status: req.Status, SubscriptionType: req.SubscriptionType, DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(), MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(), LongContextPricingEnabled: req.LongContextPricingEnabled, ModelPricing: req.ModelPricing, AllowImageGeneration: req.AllowImageGeneration, AllowBatchImageGeneration: req.AllowBatchImageGeneration, ImageRateIndependent: req.ImageRateIndependent, ImageRateMultiplier: req.ImageRateMultiplier, BatchImageDiscountMultiplier: req.BatchImageDiscountMultiplier, BatchImageHoldMultiplier: req.BatchImageHoldMultiplier, VideoRateIndependent: req.VideoRateIndependent, VideoRateMultiplier: req.VideoRateMultiplier, PeakRateEnabled: req.PeakRateEnabled, PeakStart: req.PeakStart, PeakEnd: req.PeakEnd, PeakRateMultiplier: req.PeakRateMultiplier, ProfitControlEnabled: req.ProfitControlEnabled, ProfitMinMargin: req.ProfitMinMargin, ProfitSafetyBuffer: req.ProfitSafetyBuffer, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, VideoPrice480P: req.VideoPrice480P, VideoPrice720P: req.VideoPrice720P, VideoPrice1080P: req.VideoPrice1080P, VideoModelPrices: req.VideoModelPrices, WebSearchPricePerCall: req.WebSearchPricePerCall, SearchPricePer1k: req.SearchPricePer1k, AudioRealtimePricePerMin: req.AudioRealtimePricePerMin, AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars, AudioSTTPricePerHour: req.AudioSttPricePerHour, ClaudeCodeOnly: req.ClaudeCodeOnly, FallbackGroupID: req.FallbackGroupID, FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest, ModelRouting: req.ModelRouting, ModelRoutingEnabled: req.ModelRoutingEnabled, MCPXMLInject: req.MCPXMLInject, SupportedModelScopes: req.SupportedModelScopes, AllowMessagesDispatch: req.AllowMessagesDispatch, AllowLive: req.AllowLive, RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, MessagesDispatchModelConfig: req.MessagesDispatchModelConfig, ModelsListConfig: req.ModelsListConfig, RPMLimit: req.RPMLimit, MaxReasoningEffort: req.MaxReasoningEffort, ReasoningEffortMappings: req.ReasoningEffortMappings, CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, dto.GroupFromServiceAdmin(group)) } // Delete handles deleting a group // DELETE /api/v1/admin/groups/:id func (h *GroupHandler) Delete(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } err = h.adminService.DeleteGroup(c.Request.Context(), groupID) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"message": "Group deleted successfully"}) } // GetStats handles getting group statistics // GET /api/v1/admin/groups/:id/stats func (h *GroupHandler) GetStats(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } // Return mock data for now response.Success(c, gin.H{ "total_api_keys": 0, "active_api_keys": 0, "total_requests": 0, "total_cost": 0.0, }) _ = groupID // TODO: implement actual stats } // GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups. // GET /api/v1/admin/groups/usage-summary func (h *GroupHandler) GetUsageSummary(c *gin.Context) { todayStart := service.GroupUsageTodayStart(time.Now()) results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart) if err != nil { response.Error(c, 500, "Failed to get group usage summary") return } response.Success(c, results) } // GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups. // GET /api/v1/admin/groups/capacity-summary func (h *GroupHandler) GetCapacitySummary(c *gin.Context) { results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context()) if err != nil { response.Error(c, 500, "Failed to get group capacity summary") return } response.Success(c, results) } // GetGroupAPIKeys handles getting API keys in a group // GET /api/v1/admin/groups/:id/api-keys func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } page, pageSize := response.ParsePagination(c) keys, total, err := h.adminService.GetGroupAPIKeys(c.Request.Context(), groupID, page, pageSize) if err != nil { response.ErrorFrom(c, err) return } outKeys := make([]dto.APIKey, 0, len(keys)) for i := range keys { outKeys = append(outKeys, *dto.APIKeyFromService(&keys[i])) } response.Paginated(c, outKeys, total, page, pageSize) } // GetGroupRateMultipliers handles getting rate multipliers for users in a group // GET /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } entries, err := h.adminService.GetGroupRateMultipliers(c.Request.Context(), groupID) if err != nil { response.ErrorFrom(c, err) return } if entries == nil { entries = []service.UserGroupRateEntry{} } response.Success(c, entries) } // ClearGroupRateMultipliers handles clearing all rate multipliers for a group // DELETE /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } if err := h.adminService.ClearGroupRateMultipliers(c.Request.Context(), groupID); err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"message": "Rate multipliers cleared successfully"}) } // BatchSetGroupRateMultipliersRequest represents batch set rate multipliers request type BatchSetGroupRateMultipliersRequest struct { Entries []service.GroupRateMultiplierInput `json:"entries" binding:"required"` } // BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group // PUT /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } var req BatchSetGroupRateMultipliersRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } if err := h.adminService.BatchSetGroupRateMultipliers(c.Request.Context(), groupID, req.Entries); err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"message": "Rate multipliers updated successfully"}) } // BatchSetGroupRPMOverridesRequest represents batch set rpm_override request type BatchSetGroupRPMOverridesRequest struct { Entries []service.GroupRPMOverrideInput `json:"entries" binding:"required"` } // BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group // PUT /api/v1/admin/groups/:id/rpm-overrides func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } var req BatchSetGroupRPMOverridesRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } if err := h.adminService.BatchSetGroupRPMOverrides(c.Request.Context(), groupID, req.Entries); err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"message": "RPM overrides updated successfully"}) } // ClearGroupRPMOverrides handles clearing all rpm_override for a group // DELETE /api/v1/admin/groups/:id/rpm-overrides func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) { groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } if err := h.adminService.ClearGroupRPMOverrides(c.Request.Context(), groupID); err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"message": "RPM overrides cleared successfully"}) } // UpdateSortOrderRequest represents the request to update group sort orders type UpdateSortOrderRequest struct { Updates []struct { ID int64 `json:"id" binding:"required"` SortOrder int `json:"sort_order"` } `json:"updates" binding:"required,min=1"` } // UpdateSortOrder handles updating group sort orders // PUT /api/v1/admin/groups/sort-order func (h *GroupHandler) UpdateSortOrder(c *gin.Context) { var req UpdateSortOrderRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } updates := make([]service.GroupSortOrderUpdate, 0, len(req.Updates)) for _, u := range req.Updates { updates = append(updates, service.GroupSortOrderUpdate{ ID: u.ID, SortOrder: u.SortOrder, }) } if err := h.adminService.UpdateGroupSortOrders(c.Request.Context(), updates); err != nil { response.ErrorFrom(c, err) return } response.Success(c, gin.H{"message": "Sort order updated successfully"}) }