diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 4b157d9bbb..3aa3962b0d 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -26,6 +26,7 @@ type stubAdminService struct { createdAccounts []*service.CreateAccountInput createdGroups []*service.CreateGroupInput updatedGroups []*service.UpdateGroupInput + advancedGroupOperationCalls int createdProxies []*service.CreateProxyInput updatedProxyIDs []int64 updatedProxies []*service.UpdateProxyInput @@ -297,6 +298,7 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id } func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) { + s.advancedGroupOperationCalls++ return []service.CompositeModelRoute{ { ID: 1, @@ -313,6 +315,7 @@ func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int6 } func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) { + s.advancedGroupOperationCalls++ return &service.CompositeModelRoute{ ID: 1, GroupID: groupID, @@ -328,6 +331,7 @@ func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int } func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) { + s.advancedGroupOperationCalls++ return &service.CompositeModelRoute{ ID: routeID, GroupID: groupID, @@ -343,10 +347,12 @@ func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, ro } func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error { + s.advancedGroupOperationCalls++ return nil } func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) { + s.advancedGroupOperationCalls++ decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint) if err != nil { return nil, err @@ -361,6 +367,7 @@ func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.Creat } func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) { + s.advancedGroupOperationCalls++ group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"} return &group, nil } @@ -388,10 +395,12 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) ( } func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error { + s.advancedGroupOperationCalls++ return nil } func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error { + s.advancedGroupOperationCalls++ return nil } @@ -400,6 +409,7 @@ func (s *stubAdminService) ClearGroupRPMOverrides(_ context.Context, _ int64) er } func (s *stubAdminService) BatchSetGroupRPMOverrides(_ context.Context, _ int64, _ []service.GroupRPMOverrideInput) error { + s.advancedGroupOperationCalls++ return nil } diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index e9eccd9040..c8986e2f18 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -105,52 +105,57 @@ func (h *GroupHandler) isSimpleMode() bool { return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple } +func (h *GroupHandler) rejectSimpleModeAdvancedOperation(c *gin.Context) bool { + if !h.isSimpleMode() { + return false + } + response.Forbidden(c, "This operation is not supported in simple mode") + return true +} + +type simpleModeGroupResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + Status string `json:"status"` + AccountGroups []dto.AccountGroup `json:"account_groups,omitempty"` + AccountCount int64 `json:"account_count,omitempty"` + ActiveAccountCount int64 `json:"active_account_count,omitempty"` + RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse { + if group == nil { + return nil + } + adminGroup := dto.GroupFromServiceAdmin(group) + return &simpleModeGroupResponse{ + ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform, + Status: group.Status, AccountGroups: adminGroup.AccountGroups, AccountCount: group.AccountCount, + ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount, + SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt, + } +} + func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) { if req == nil { return } - req.RateMultiplier = 1 - req.IsExclusive = false - req.SubscriptionType = service.SubscriptionTypeStandard - req.DailyLimitUSD, req.WeeklyLimitUSD, req.MonthlyLimitUSD = optionalLimitField{}, optionalLimitField{}, optionalLimitField{} - req.AllowImageGeneration, req.AllowBatchImageGeneration = false, false - req.ImageRateIndependent, req.ImageRateMultiplier = false, nil - req.ImagePrice1K, req.ImagePrice2K, req.ImagePrice4K = nil, nil, nil - req.VideoRateIndependent, req.VideoRateMultiplier = false, nil - req.PeakRateEnabled, req.PeakStart, req.PeakEnd, req.PeakRateMultiplier = false, "", "", nil - req.ProfitControlEnabled, req.ProfitMinMargin, req.ProfitSafetyBuffer = false, nil, nil - req.ClaudeCodeOnly, req.FallbackGroupID, req.FallbackGroupIDOnInvalidRequest = false, nil, nil - req.ModelRouting, req.ModelRoutingEnabled, req.MCPXMLInject = nil, false, nil - req.SupportedModelScopes, req.AllowMessagesDispatch, req.AllowLive = nil, false, false - req.RequireOAuthOnly, req.RequirePrivacySet, req.DefaultMappedModel = false, false, "" - req.MessagesDispatchModelConfig, req.ModelsListConfig = service.OpenAIMessagesDispatchModelConfig{}, service.GroupModelsListConfig{} - req.RPMLimit, req.MaxReasoningEffort, req.ReasoningEffortMappings = 0, "", nil - req.CopyAccountsFromGroupIDs = nil + allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform} + allowed.RateMultiplier = 1 + allowed.SubscriptionType = service.SubscriptionTypeStandard + *req = allowed } func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) { if req == nil { return } - rate, exclusive, disabled, image, messages, live, oauth, privacy := 1.0, false, false, false, false, false, false, false - rpm := 0 - req.RateMultiplier, req.IsExclusive = &rate, &exclusive - req.Status, req.SubscriptionType = "", service.SubscriptionTypeStandard - req.DailyLimitUSD, req.WeeklyLimitUSD, req.MonthlyLimitUSD = optionalLimitField{}, optionalLimitField{}, optionalLimitField{} - req.AllowImageGeneration, req.AllowBatchImageGeneration = &image, &image - req.ImageRateIndependent, req.ImageRateMultiplier = &disabled, nil - req.ImagePrice1K, req.ImagePrice2K, req.ImagePrice4K = nil, nil, nil - req.VideoRateIndependent, req.VideoRateMultiplier = &disabled, nil - req.PeakRateEnabled, req.PeakStart, req.PeakEnd, req.PeakRateMultiplier = &disabled, nil, nil, nil - req.ProfitControlEnabled, req.ProfitMinMargin, req.ProfitSafetyBuffer = &disabled, nil, nil - req.ClaudeCodeOnly, req.FallbackGroupID, req.FallbackGroupIDOnInvalidRequest = &disabled, nil, nil - req.ModelRouting, req.ModelRoutingEnabled, req.MCPXMLInject = nil, &disabled, nil - req.SupportedModelScopes = nil - req.AllowMessagesDispatch, req.AllowLive = &messages, &live - req.RequireOAuthOnly, req.RequirePrivacySet = &oauth, &privacy - req.DefaultMappedModel, req.MessagesDispatchModelConfig, req.ModelsListConfig = nil, nil, nil - req.RPMLimit, req.MaxReasoningEffort, req.ReasoningEffortMappings = &rpm, nil, nil - req.CopyAccountsFromGroupIDs = nil + *req = UpdateGroupRequest{Name: req.Name, Description: req.Description} } // CreateGroupRequest represents create group request @@ -346,6 +351,14 @@ func (h *GroupHandler) List(c *gin.Context) { return } + if h.isSimpleMode() { + simpleGroups := make([]simpleModeGroupResponse, 0, len(groups)) + for i := range groups { + simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i])) + } + response.Paginated(c, simpleGroups, total, page, pageSize) + return + } outGroups := make([]dto.AdminGroup, 0, len(groups)) for i := range groups { outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) @@ -356,6 +369,9 @@ func (h *GroupHandler) List(c *gin.Context) { // ListCompositeRoutes handles listing composite model routes for one group. // GET /api/v1/admin/groups/:id/composite-routes func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -371,6 +387,9 @@ func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) { // CreateCompositeRoute handles creating one composite model route. // POST /api/v1/admin/groups/:id/composite-routes func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -391,6 +410,9 @@ func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) { // UpdateCompositeRoute handles replacing one composite model route. // PUT /api/v1/admin/groups/:id/composite-routes/:route_id func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -415,6 +437,9 @@ func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) { // DeleteCompositeRoute handles deleting one composite model route. // DELETE /api/v1/admin/groups/:id/composite-routes/:route_id func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -433,6 +458,9 @@ func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) { // PreviewCompositeRoute resolves a model without mutating routes. // POST /api/v1/admin/groups/:id/composite-routes/preview func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -505,6 +533,14 @@ func (h *GroupHandler) GetAll(c *gin.Context) { return } + if h.isSimpleMode() { + simpleGroups := make([]simpleModeGroupResponse, 0, len(groups)) + for i := range groups { + simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i])) + } + response.Success(c, simpleGroups) + return + } outGroups := make([]dto.AdminGroup, 0, len(groups)) for i := range groups { outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) @@ -527,12 +563,19 @@ func (h *GroupHandler) GetByID(c *gin.Context) { return } + if h.isSimpleMode() { + response.Success(c, groupForSimpleMode(group)) + 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) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || groupID < 0 { response.BadRequest(c, "Invalid group ID") @@ -643,12 +686,19 @@ func (h *GroupHandler) Create(c *gin.Context) { return } + if h.isSimpleMode() { + response.Success(c, groupForSimpleMode(group)) + 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) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || groupID <= 0 { response.BadRequest(c, "Invalid group ID") @@ -779,6 +829,10 @@ func (h *GroupHandler) Update(c *gin.Context) { return } + if h.isSimpleMode() { + response.Success(c, groupForSimpleMode(group)) + return + } response.Success(c, dto.GroupFromServiceAdmin(group)) } @@ -871,6 +925,9 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { // 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) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -892,6 +949,9 @@ func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) { // ClearGroupRateMultipliers handles clearing all rate multipliers for a group // DELETE /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -914,6 +974,9 @@ type BatchSetGroupRateMultipliersRequest struct { // BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group // PUT /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -942,6 +1005,9 @@ type BatchSetGroupRPMOverridesRequest struct { // 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) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -965,6 +1031,9 @@ func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) { // ClearGroupRPMOverrides handles clearing all rpm_override for a group // DELETE /api/v1/admin/groups/:id/rpm-overrides func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) { + if h.rejectSimpleModeAdvancedOperation(c) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") diff --git a/backend/internal/handler/admin/group_handler_simple_mode_test.go b/backend/internal/handler/admin/group_handler_simple_mode_test.go index 207925bc1e..0b52d74657 100644 --- a/backend/internal/handler/admin/group_handler_simple_mode_test.go +++ b/backend/internal/handler/admin/group_handler_simple_mode_test.go @@ -2,6 +2,7 @@ package admin import ( "bytes" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -12,15 +13,30 @@ import ( "github.com/stretchr/testify/require" ) +func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine { + h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple}) + r := gin.New() + r.GET("/groups", h.List) + r.GET("/groups/all", h.GetAll) + r.GET("/groups/:id", h.GetByID) + r.GET("/groups/:id/models-list-candidates", h.GetModelsListCandidates) + r.POST("/groups", h.Create) + r.PUT("/groups/:id", h.Update) + r.POST("/groups/:id/duplicate", h.Duplicate) + r.GET("/groups/:id/composite-routes", h.ListCompositeRoutes) + r.POST("/groups/:id/composite-routes", h.CreateCompositeRoute) + r.PUT("/groups/:id/rate-multipliers", h.BatchSetGroupRateMultipliers) + r.DELETE("/groups/:id/rate-multipliers", h.ClearGroupRateMultipliers) + r.PUT("/groups/:id/rpm-overrides", h.BatchSetGroupRPMOverrides) + return r +} + func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) { gin.SetMode(gin.TestMode) svc := newStubAdminService() - h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple}) - r := gin.New() - r.POST("/groups", h.Create) - r.PUT("/groups/:id", h.Update) + r := newSimpleModeGroupRouter(svc) - create := `{"name":"simple","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"allow_image_generation":true,"rpm_limit":99}` + create := `{"name":"simple","description":"basic grouping","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"rpm_limit":99}` req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(create)) req.Header.Set("Content-Type", "application/json") res := httptest.NewRecorder() @@ -28,14 +44,21 @@ func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) { require.Equal(t, http.StatusOK, res.Code) require.Len(t, svc.createdGroups, 1) created := svc.createdGroups[0] + require.Equal(t, "basic grouping", created.Description) require.Equal(t, 1.0, created.RateMultiplier) require.False(t, created.IsExclusive) require.Equal(t, service.SubscriptionTypeStandard, created.SubscriptionType) require.Nil(t, created.DailyLimitUSD) require.False(t, created.AllowImageGeneration) + require.False(t, created.LongContextPricingEnabled) + require.Empty(t, created.ModelPricing) + require.False(t, created.AllowBatchImageGeneration) + require.Nil(t, created.VideoPrice720P) + require.Nil(t, created.WebSearchPricePerCall) + require.Nil(t, created.AudioRealtimePricePerMin) require.Zero(t, created.RPMLimit) - update := `{"name":"renamed","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"allow_image_generation":true,"status":"inactive","rpm_limit":123}` + update := `{"name":"renamed","description":"still basic","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"status":"inactive","rpm_limit":123}` req = httptest.NewRequest(http.MethodPut, "/groups/2", bytes.NewBufferString(update)) req.Header.Set("Content-Type", "application/json") res = httptest.NewRecorder() @@ -43,15 +66,149 @@ func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) { require.Equal(t, http.StatusOK, res.Code) require.Len(t, svc.updatedGroups, 1) updated := svc.updatedGroups[0] - require.NotNil(t, updated.RateMultiplier) - require.Equal(t, 1.0, *updated.RateMultiplier) - require.NotNil(t, updated.IsExclusive) - require.False(t, *updated.IsExclusive) - require.Equal(t, service.SubscriptionTypeStandard, updated.SubscriptionType) + require.NotNil(t, updated.Description) + require.Equal(t, "still basic", *updated.Description) + require.Nil(t, updated.RateMultiplier) + require.Nil(t, updated.IsExclusive) + require.Empty(t, updated.SubscriptionType) require.Nil(t, updated.DailyLimitUSD) - require.NotNil(t, updated.AllowImageGeneration) - require.False(t, *updated.AllowImageGeneration) + require.Nil(t, updated.AllowImageGeneration) + require.Nil(t, updated.LongContextPricingEnabled) + require.Nil(t, updated.ModelPricing) + require.Nil(t, updated.AllowBatchImageGeneration) + require.Nil(t, updated.VideoPrice720P) + require.Nil(t, updated.WebSearchPricePerCall) + require.Nil(t, updated.AudioRealtimePricePerMin) require.Empty(t, updated.Status) - require.NotNil(t, updated.RPMLimit) - require.Zero(t, *updated.RPMLimit) + require.Nil(t, updated.RPMLimit) +} + +func float64PtrForSimpleModeTest(value float64) *float64 { return &value } + +func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newStubAdminService() + svc.groups = []service.Group{{ + ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, + Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42, + LongContextPricingEnabled: true, + ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}}, + AllowBatchImageGeneration: true, VideoPrice720P: float64PtrForSimpleModeTest(2), + WebSearchPricePerCall: float64PtrForSimpleModeTest(3), AudioRealtimePricePerMin: float64PtrForSimpleModeTest(4), + ModelRouting: map[string][]int64{"claude": {2}}, + }} + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups", nil)) + require.Equal(t, http.StatusOK, res.Code) + + var payload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 1) + item := payload.Data.Items[0] + require.Equal(t, "basic", item["name"]) + require.Equal(t, "allowed", item["description"]) + require.Equal(t, "anthropic", item["platform"]) + for _, forbidden := range []string{ + "rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing", + "allow_batch_image_generation", "video_price_720p", "web_search_price_per_call", + "audio_realtime_price_per_min", "model_routing", + } { + _, exposed := item[forbidden] + require.Falsef(t, exposed, "simple mode exposed %s", forbidden) + } +} + +func TestGroupHandlerSimpleModeAllReadAndWriteResponsesUseFieldAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newStubAdminService() + svc.groups = []service.Group{{ + ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, + Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42, + LongContextPricingEnabled: true, + ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}}, + }} + r := newSimpleModeGroupRouter(svc) + + assertNoAdvancedFields := func(t *testing.T, item map[string]any) { + t.Helper() + for _, forbidden := range []string{ + "rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing", + "allow_batch_image_generation", "video_price_720p", "web_search_price_per_call", + "audio_realtime_price_per_min", "model_routing", + } { + _, exposed := item[forbidden] + require.Falsef(t, exposed, "simple mode exposed %s", forbidden) + } + } + + for _, tt := range []struct { + name string + method string + path string + body string + list bool + }{ + {name: "get all", method: http.MethodGet, path: "/groups/all", list: true}, + {name: "get by id", method: http.MethodGet, path: "/groups/1"}, + {name: "create", method: http.MethodPost, path: "/groups", body: `{"name":"created","platform":"anthropic","model_pricing":[{"models":["claude"]}]}`}, + {name: "update", method: http.MethodPut, path: "/groups/1", body: `{"name":"updated","model_pricing":[{"models":["claude"]}]}`}, + } { + t.Run(tt.name, func(t *testing.T) { + res := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + + var payload struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload)) + if tt.list { + var items []map[string]any + require.NoError(t, json.Unmarshal(payload.Data, &items)) + require.Len(t, items, 1) + assertNoAdvancedFields(t, items[0]) + return + } + var item map[string]any + require.NoError(t, json.Unmarshal(payload.Data, &item)) + assertNoAdvancedFields(t, item) + }) + } +} + +func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + method string + path string + body string + }{ + {http.MethodPost, "/groups/1/duplicate", ""}, + {http.MethodGet, "/groups/1/models-list-candidates", ""}, + {http.MethodGet, "/groups/1/composite-routes", ""}, + {http.MethodPost, "/groups/1/composite-routes", `{"public_model":"x","target_platform":"openai"}`}, + {http.MethodPut, "/groups/1/rate-multipliers", `{"entries":[]}`}, + {http.MethodDelete, "/groups/1/rate-multipliers", ""}, + {http.MethodPut, "/groups/1/rpm-overrides", `{"entries":[]}`}, + } + + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + svc := newStubAdminService() + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusForbidden, res.Code) + require.Zero(t, svc.advancedGroupOperationCalls) + }) + } } diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 0e75dc9258..d35245ecbf 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -250,7 +250,7 @@ var ProviderSet = wire.NewSet( // Admin handlers admin.NewDashboardHandler, admin.NewUserHandler, - admin.NewGroupHandler, + admin.NewGroupHandlerWithConfig, admin.ProvideAccountHandler, admin.NewAnnouncementHandler, admin.NewDataManagementHandler, diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 64a60d1f04..e402301ff1 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -383,6 +383,7 @@ {{ t("common.edit") }}