Merge pull request #2674 from wucm667/feat/moderation-per-model-toggle
feat(risk-control): 内容审计支持按模型生效
This commit is contained in:
commit
f59d9a5f8e
@ -20,34 +20,35 @@ func NewContentModerationHandler(svc *service.ContentModerationService) *Content
|
|||||||
}
|
}
|
||||||
|
|
||||||
type contentModerationConfigRequest struct {
|
type contentModerationConfigRequest struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
Mode *string `json:"mode"`
|
Mode *string `json:"mode"`
|
||||||
BaseURL *string `json:"base_url"`
|
BaseURL *string `json:"base_url"`
|
||||||
Model *string `json:"model"`
|
Model *string `json:"model"`
|
||||||
APIKey *string `json:"api_key"`
|
APIKey *string `json:"api_key"`
|
||||||
APIKeys *[]string `json:"api_keys"`
|
APIKeys *[]string `json:"api_keys"`
|
||||||
APIKeysMode string `json:"api_keys_mode"`
|
APIKeysMode string `json:"api_keys_mode"`
|
||||||
DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
|
DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
|
||||||
ClearAPIKey bool `json:"clear_api_key"`
|
ClearAPIKey bool `json:"clear_api_key"`
|
||||||
TimeoutMS *int `json:"timeout_ms"`
|
TimeoutMS *int `json:"timeout_ms"`
|
||||||
SampleRate *int `json:"sample_rate"`
|
SampleRate *int `json:"sample_rate"`
|
||||||
AllGroups *bool `json:"all_groups"`
|
AllGroups *bool `json:"all_groups"`
|
||||||
GroupIDs *[]int64 `json:"group_ids"`
|
GroupIDs *[]int64 `json:"group_ids"`
|
||||||
RecordNonHits *bool `json:"record_non_hits"`
|
RecordNonHits *bool `json:"record_non_hits"`
|
||||||
WorkerCount *int `json:"worker_count"`
|
WorkerCount *int `json:"worker_count"`
|
||||||
QueueSize *int `json:"queue_size"`
|
QueueSize *int `json:"queue_size"`
|
||||||
BlockStatus *int `json:"block_status"`
|
BlockStatus *int `json:"block_status"`
|
||||||
BlockMessage *string `json:"block_message"`
|
BlockMessage *string `json:"block_message"`
|
||||||
EmailOnHit *bool `json:"email_on_hit"`
|
EmailOnHit *bool `json:"email_on_hit"`
|
||||||
AutoBanEnabled *bool `json:"auto_ban_enabled"`
|
AutoBanEnabled *bool `json:"auto_ban_enabled"`
|
||||||
BanThreshold *int `json:"ban_threshold"`
|
BanThreshold *int `json:"ban_threshold"`
|
||||||
ViolationWindowHours *int `json:"violation_window_hours"`
|
ViolationWindowHours *int `json:"violation_window_hours"`
|
||||||
RetryCount *int `json:"retry_count"`
|
RetryCount *int `json:"retry_count"`
|
||||||
HitRetentionDays *int `json:"hit_retention_days"`
|
HitRetentionDays *int `json:"hit_retention_days"`
|
||||||
NonHitRetentionDays *int `json:"non_hit_retention_days"`
|
NonHitRetentionDays *int `json:"non_hit_retention_days"`
|
||||||
PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
|
PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
|
||||||
BlockedKeywords *[]string `json:"blocked_keywords"`
|
BlockedKeywords *[]string `json:"blocked_keywords"`
|
||||||
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
|
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
|
||||||
|
ModelFilter *service.ContentModerationModelFilter `json:"model_filter"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type contentModerationAPIKeyTestRequest struct {
|
type contentModerationAPIKeyTestRequest struct {
|
||||||
@ -107,6 +108,7 @@ func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
|
|||||||
PreHashCheckEnabled: req.PreHashCheckEnabled,
|
PreHashCheckEnabled: req.PreHashCheckEnabled,
|
||||||
BlockedKeywords: req.BlockedKeywords,
|
BlockedKeywords: req.BlockedKeywords,
|
||||||
KeywordBlockingMode: req.KeywordBlockingMode,
|
KeywordBlockingMode: req.KeywordBlockingMode,
|
||||||
|
ModelFilter: req.ModelFilter,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
|
|||||||
@ -44,6 +44,10 @@ const (
|
|||||||
ContentModerationKeywordModeKeywordAndAPI = "keyword_and_api"
|
ContentModerationKeywordModeKeywordAndAPI = "keyword_and_api"
|
||||||
ContentModerationKeywordModeAPIOnly = "api_only"
|
ContentModerationKeywordModeAPIOnly = "api_only"
|
||||||
|
|
||||||
|
ContentModerationModelFilterAll = "all"
|
||||||
|
ContentModerationModelFilterInclude = "include"
|
||||||
|
ContentModerationModelFilterExclude = "exclude"
|
||||||
|
|
||||||
ContentModerationProtocolAnthropicMessages = "anthropic_messages"
|
ContentModerationProtocolAnthropicMessages = "anthropic_messages"
|
||||||
ContentModerationProtocolOpenAIResponses = "openai_responses"
|
ContentModerationProtocolOpenAIResponses = "openai_responses"
|
||||||
ContentModerationProtocolOpenAIChat = "openai_chat_completions"
|
ContentModerationProtocolOpenAIChat = "openai_chat_completions"
|
||||||
@ -80,6 +84,8 @@ const (
|
|||||||
maxContentModerationTestImageDataURLBytes = 12 * 1024 * 1024
|
maxContentModerationTestImageDataURLBytes = 12 * 1024 * 1024
|
||||||
maxContentModerationBlockedKeywords = 10000
|
maxContentModerationBlockedKeywords = 10000
|
||||||
maxContentModerationBlockedKeywordRunes = 200
|
maxContentModerationBlockedKeywordRunes = 200
|
||||||
|
maxContentModerationModelFilterModels = 1000
|
||||||
|
maxContentModerationModelFilterRunes = 200
|
||||||
|
|
||||||
contentModerationCleanupInterval = 24 * time.Hour
|
contentModerationCleanupInterval = 24 * time.Hour
|
||||||
contentModerationCleanupTimeout = 30 * time.Minute
|
contentModerationCleanupTimeout = 30 * time.Minute
|
||||||
@ -127,32 +133,33 @@ func ContentModerationCategories() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ContentModerationConfig struct {
|
type ContentModerationConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
APIKey string `json:"api_key,omitempty"`
|
APIKey string `json:"api_key,omitempty"`
|
||||||
APIKeys []string `json:"api_keys,omitempty"`
|
APIKeys []string `json:"api_keys,omitempty"`
|
||||||
TimeoutMS int `json:"timeout_ms"`
|
TimeoutMS int `json:"timeout_ms"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
AllGroups bool `json:"all_groups"`
|
AllGroups bool `json:"all_groups"`
|
||||||
GroupIDs []int64 `json:"group_ids"`
|
GroupIDs []int64 `json:"group_ids"`
|
||||||
RecordNonHits bool `json:"record_non_hits"`
|
RecordNonHits bool `json:"record_non_hits"`
|
||||||
Thresholds map[string]float64 `json:"thresholds"`
|
Thresholds map[string]float64 `json:"thresholds"`
|
||||||
WorkerCount int `json:"worker_count"`
|
WorkerCount int `json:"worker_count"`
|
||||||
QueueSize int `json:"queue_size"`
|
QueueSize int `json:"queue_size"`
|
||||||
BlockStatus int `json:"block_status"`
|
BlockStatus int `json:"block_status"`
|
||||||
BlockMessage string `json:"block_message"`
|
BlockMessage string `json:"block_message"`
|
||||||
EmailOnHit bool `json:"email_on_hit"`
|
EmailOnHit bool `json:"email_on_hit"`
|
||||||
AutoBanEnabled bool `json:"auto_ban_enabled"`
|
AutoBanEnabled bool `json:"auto_ban_enabled"`
|
||||||
BanThreshold int `json:"ban_threshold"`
|
BanThreshold int `json:"ban_threshold"`
|
||||||
ViolationWindowHours int `json:"violation_window_hours"`
|
ViolationWindowHours int `json:"violation_window_hours"`
|
||||||
RetryCount int `json:"retry_count"`
|
RetryCount int `json:"retry_count"`
|
||||||
HitRetentionDays int `json:"hit_retention_days"`
|
HitRetentionDays int `json:"hit_retention_days"`
|
||||||
NonHitRetentionDays int `json:"non_hit_retention_days"`
|
NonHitRetentionDays int `json:"non_hit_retention_days"`
|
||||||
PreHashCheckEnabled bool `json:"pre_hash_check_enabled"`
|
PreHashCheckEnabled bool `json:"pre_hash_check_enabled"`
|
||||||
BlockedKeywords []string `json:"blocked_keywords"`
|
BlockedKeywords []string `json:"blocked_keywords"`
|
||||||
KeywordBlockingMode string `json:"keyword_blocking_mode"`
|
KeywordBlockingMode string `json:"keyword_blocking_mode"`
|
||||||
|
ModelFilter ContentModerationModelFilter `json:"model_filter"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentModerationConfigView struct {
|
type ContentModerationConfigView struct {
|
||||||
@ -184,6 +191,7 @@ type ContentModerationConfigView struct {
|
|||||||
PreHashCheckEnabled bool `json:"pre_hash_check_enabled"`
|
PreHashCheckEnabled bool `json:"pre_hash_check_enabled"`
|
||||||
BlockedKeywords []string `json:"blocked_keywords"`
|
BlockedKeywords []string `json:"blocked_keywords"`
|
||||||
KeywordBlockingMode string `json:"keyword_blocking_mode"`
|
KeywordBlockingMode string `json:"keyword_blocking_mode"`
|
||||||
|
ModelFilter ContentModerationModelFilter `json:"model_filter"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentModerationAPIKeyStatus struct {
|
type ContentModerationAPIKeyStatus struct {
|
||||||
@ -227,34 +235,40 @@ type ContentModerationTestAuditResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateContentModerationConfigInput struct {
|
type UpdateContentModerationConfigInput struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
Mode *string `json:"mode"`
|
Mode *string `json:"mode"`
|
||||||
BaseURL *string `json:"base_url"`
|
BaseURL *string `json:"base_url"`
|
||||||
Model *string `json:"model"`
|
Model *string `json:"model"`
|
||||||
APIKey *string `json:"api_key"`
|
APIKey *string `json:"api_key"`
|
||||||
APIKeys *[]string `json:"api_keys"`
|
APIKeys *[]string `json:"api_keys"`
|
||||||
APIKeysMode string `json:"api_keys_mode"`
|
APIKeysMode string `json:"api_keys_mode"`
|
||||||
DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
|
DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
|
||||||
ClearAPIKey bool `json:"clear_api_key"`
|
ClearAPIKey bool `json:"clear_api_key"`
|
||||||
TimeoutMS *int `json:"timeout_ms"`
|
TimeoutMS *int `json:"timeout_ms"`
|
||||||
SampleRate *int `json:"sample_rate"`
|
SampleRate *int `json:"sample_rate"`
|
||||||
AllGroups *bool `json:"all_groups"`
|
AllGroups *bool `json:"all_groups"`
|
||||||
GroupIDs *[]int64 `json:"group_ids"`
|
GroupIDs *[]int64 `json:"group_ids"`
|
||||||
RecordNonHits *bool `json:"record_non_hits"`
|
RecordNonHits *bool `json:"record_non_hits"`
|
||||||
WorkerCount *int `json:"worker_count"`
|
WorkerCount *int `json:"worker_count"`
|
||||||
QueueSize *int `json:"queue_size"`
|
QueueSize *int `json:"queue_size"`
|
||||||
BlockStatus *int `json:"block_status"`
|
BlockStatus *int `json:"block_status"`
|
||||||
BlockMessage *string `json:"block_message"`
|
BlockMessage *string `json:"block_message"`
|
||||||
EmailOnHit *bool `json:"email_on_hit"`
|
EmailOnHit *bool `json:"email_on_hit"`
|
||||||
AutoBanEnabled *bool `json:"auto_ban_enabled"`
|
AutoBanEnabled *bool `json:"auto_ban_enabled"`
|
||||||
BanThreshold *int `json:"ban_threshold"`
|
BanThreshold *int `json:"ban_threshold"`
|
||||||
ViolationWindowHours *int `json:"violation_window_hours"`
|
ViolationWindowHours *int `json:"violation_window_hours"`
|
||||||
RetryCount *int `json:"retry_count"`
|
RetryCount *int `json:"retry_count"`
|
||||||
HitRetentionDays *int `json:"hit_retention_days"`
|
HitRetentionDays *int `json:"hit_retention_days"`
|
||||||
NonHitRetentionDays *int `json:"non_hit_retention_days"`
|
NonHitRetentionDays *int `json:"non_hit_retention_days"`
|
||||||
PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
|
PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
|
||||||
BlockedKeywords *[]string `json:"blocked_keywords"`
|
BlockedKeywords *[]string `json:"blocked_keywords"`
|
||||||
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
|
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
|
||||||
|
ModelFilter *ContentModerationModelFilter `json:"model_filter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentModerationModelFilter struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Models []string `json:"models"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentModerationCheckInput struct {
|
type ContentModerationCheckInput struct {
|
||||||
@ -581,6 +595,9 @@ func (s *ContentModerationService) UpdateConfig(ctx context.Context, input Updat
|
|||||||
if input.KeywordBlockingMode != nil {
|
if input.KeywordBlockingMode != nil {
|
||||||
cfg.KeywordBlockingMode = strings.TrimSpace(*input.KeywordBlockingMode)
|
cfg.KeywordBlockingMode = strings.TrimSpace(*input.KeywordBlockingMode)
|
||||||
}
|
}
|
||||||
|
if input.ModelFilter != nil {
|
||||||
|
cfg.ModelFilter = *input.ModelFilter
|
||||||
|
}
|
||||||
if input.AllGroups != nil {
|
if input.AllGroups != nil {
|
||||||
cfg.AllGroups = *input.AllGroups
|
cfg.AllGroups = *input.AllGroups
|
||||||
}
|
}
|
||||||
@ -719,7 +736,8 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer
|
|||||||
"error", err)
|
"error", err)
|
||||||
return allow, nil
|
return allow, nil
|
||||||
}
|
}
|
||||||
inScope := cfg.includesGroup(input.GroupID)
|
inGroupScope := cfg.includesGroup(input.GroupID)
|
||||||
|
inModelScope := cfg.includesModel(input.Model)
|
||||||
slog.Info("content_moderation.config_loaded",
|
slog.Info("content_moderation.config_loaded",
|
||||||
"user_id", input.UserID,
|
"user_id", input.UserID,
|
||||||
"api_key_id", input.APIKeyID,
|
"api_key_id", input.APIKeyID,
|
||||||
@ -733,7 +751,10 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer
|
|||||||
"mode", cfg.Mode,
|
"mode", cfg.Mode,
|
||||||
"all_groups", cfg.AllGroups,
|
"all_groups", cfg.AllGroups,
|
||||||
"configured_group_ids", cfg.GroupIDs,
|
"configured_group_ids", cfg.GroupIDs,
|
||||||
"in_scope", inScope,
|
"in_group_scope", inGroupScope,
|
||||||
|
"model_filter_type", cfg.ModelFilter.Type,
|
||||||
|
"configured_models", cfg.ModelFilter.Models,
|
||||||
|
"in_model_scope", inModelScope,
|
||||||
"sample_rate", cfg.SampleRate,
|
"sample_rate", cfg.SampleRate,
|
||||||
"api_key_count", len(cfg.apiKeys()),
|
"api_key_count", len(cfg.apiKeys()),
|
||||||
"pre_hash_check_enabled", cfg.PreHashCheckEnabled,
|
"pre_hash_check_enabled", cfg.PreHashCheckEnabled,
|
||||||
@ -756,7 +777,7 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer
|
|||||||
"protocol", input.Protocol)
|
"protocol", input.Protocol)
|
||||||
return allow, nil
|
return allow, nil
|
||||||
}
|
}
|
||||||
if !inScope {
|
if !inGroupScope {
|
||||||
slog.Info("content_moderation.skip_group_out_of_scope",
|
slog.Info("content_moderation.skip_group_out_of_scope",
|
||||||
"user_id", input.UserID,
|
"user_id", input.UserID,
|
||||||
"api_key_id", input.APIKeyID,
|
"api_key_id", input.APIKeyID,
|
||||||
@ -768,6 +789,19 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer
|
|||||||
"configured_group_ids", cfg.GroupIDs)
|
"configured_group_ids", cfg.GroupIDs)
|
||||||
return allow, nil
|
return allow, nil
|
||||||
}
|
}
|
||||||
|
if !inModelScope {
|
||||||
|
slog.Info("content_moderation.skip_model_out_of_scope",
|
||||||
|
"user_id", input.UserID,
|
||||||
|
"api_key_id", input.APIKeyID,
|
||||||
|
"group_id", contentModerationLogGroupID(input.GroupID),
|
||||||
|
"group_name", input.GroupName,
|
||||||
|
"endpoint", input.Endpoint,
|
||||||
|
"protocol", input.Protocol,
|
||||||
|
"model", input.Model,
|
||||||
|
"model_filter_type", cfg.ModelFilter.Type,
|
||||||
|
"configured_models", cfg.ModelFilter.Models)
|
||||||
|
return allow, nil
|
||||||
|
}
|
||||||
content := ExtractContentModerationInput(input.Protocol, input.Body)
|
content := ExtractContentModerationInput(input.Protocol, input.Body)
|
||||||
if content.IsEmpty() {
|
if content.IsEmpty() {
|
||||||
slog.Info("content_moderation.skip_empty_input",
|
slog.Info("content_moderation.skip_empty_input",
|
||||||
@ -1025,6 +1059,9 @@ func (s *ContentModerationService) worker(id int) {
|
|||||||
if !cfg.includesGroup(task.input.GroupID) {
|
if !cfg.includesGroup(task.input.GroupID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !cfg.includesModel(task.input.Model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
s.asyncActive.Add(1)
|
s.asyncActive.Add(1)
|
||||||
defer s.asyncActive.Add(-1)
|
defer s.asyncActive.Add(-1)
|
||||||
queueDelay := int(time.Since(task.enqueuedAt).Milliseconds())
|
queueDelay := int(time.Since(task.enqueuedAt).Milliseconds())
|
||||||
@ -1270,6 +1307,9 @@ func (s *ContentModerationService) validateConfig(ctx context.Context, cfg *Cont
|
|||||||
if cfg.BlockStatus < 400 || cfg.BlockStatus > 599 {
|
if cfg.BlockStatus < 400 || cfg.BlockStatus > 599 {
|
||||||
return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BLOCK_STATUS", "拦截 HTTP 状态码必须在 400-599 之间")
|
return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BLOCK_STATUS", "拦截 HTTP 状态码必须在 400-599 之间")
|
||||||
}
|
}
|
||||||
|
if cfg.ModelFilter.Type != ContentModerationModelFilterAll && len(cfg.ModelFilter.Models) == 0 {
|
||||||
|
return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_MODEL_FILTER", "指定或排除模型时至少需要配置 1 个模型")
|
||||||
|
}
|
||||||
if !cfg.AllGroups && len(cfg.GroupIDs) > 0 && s.groupRepo != nil {
|
if !cfg.AllGroups && len(cfg.GroupIDs) > 0 && s.groupRepo != nil {
|
||||||
for _, groupID := range cfg.GroupIDs {
|
for _, groupID := range cfg.GroupIDs {
|
||||||
if _, err := s.groupRepo.GetByIDLite(ctx, groupID); err != nil {
|
if _, err := s.groupRepo.GetByIDLite(ctx, groupID); err != nil {
|
||||||
@ -1590,6 +1630,10 @@ func defaultContentModerationConfig() *ContentModerationConfig {
|
|||||||
PreHashCheckEnabled: false,
|
PreHashCheckEnabled: false,
|
||||||
BlockedKeywords: []string{},
|
BlockedKeywords: []string{},
|
||||||
KeywordBlockingMode: ContentModerationKeywordModeKeywordAndAPI,
|
KeywordBlockingMode: ContentModerationKeywordModeKeywordAndAPI,
|
||||||
|
ModelFilter: ContentModerationModelFilter{
|
||||||
|
Type: ContentModerationModelFilterAll,
|
||||||
|
Models: []string{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1670,6 +1714,7 @@ func (cfg *ContentModerationConfig) normalize() {
|
|||||||
cfg.Thresholds = mergeContentModerationThresholds(ContentModerationDefaultThresholds(), cfg.Thresholds)
|
cfg.Thresholds = mergeContentModerationThresholds(ContentModerationDefaultThresholds(), cfg.Thresholds)
|
||||||
cfg.BlockedKeywords = normalizeBlockedKeywords(cfg.BlockedKeywords)
|
cfg.BlockedKeywords = normalizeBlockedKeywords(cfg.BlockedKeywords)
|
||||||
cfg.KeywordBlockingMode = normalizeKeywordBlockingMode(cfg.KeywordBlockingMode)
|
cfg.KeywordBlockingMode = normalizeKeywordBlockingMode(cfg.KeywordBlockingMode)
|
||||||
|
cfg.ModelFilter = normalizeContentModerationModelFilter(cfg.ModelFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *ContentModerationConfig) includesGroup(groupID *int64) bool {
|
func (cfg *ContentModerationConfig) includesGroup(groupID *int64) bool {
|
||||||
@ -1687,6 +1732,21 @@ func (cfg *ContentModerationConfig) includesGroup(groupID *int64) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg *ContentModerationConfig) includesModel(model string) bool {
|
||||||
|
if cfg == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
filter := normalizeContentModerationModelFilter(cfg.ModelFilter)
|
||||||
|
switch filter.Type {
|
||||||
|
case ContentModerationModelFilterInclude:
|
||||||
|
return contentModerationModelListContains(filter.Models, model)
|
||||||
|
case ContentModerationModelFilterExclude:
|
||||||
|
return !contentModerationModelListContains(filter.Models, model)
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contentModerationLogGroupID(groupID *int64) int64 {
|
func contentModerationLogGroupID(groupID *int64) int64 {
|
||||||
if groupID == nil {
|
if groupID == nil {
|
||||||
return 0
|
return 0
|
||||||
@ -1848,6 +1908,7 @@ func (s *ContentModerationService) configView(cfg *ContentModerationConfig) *Con
|
|||||||
PreHashCheckEnabled: cfg.PreHashCheckEnabled,
|
PreHashCheckEnabled: cfg.PreHashCheckEnabled,
|
||||||
BlockedKeywords: append([]string(nil), cfg.BlockedKeywords...),
|
BlockedKeywords: append([]string(nil), cfg.BlockedKeywords...),
|
||||||
KeywordBlockingMode: cfg.KeywordBlockingMode,
|
KeywordBlockingMode: cfg.KeywordBlockingMode,
|
||||||
|
ModelFilter: cloneContentModerationModelFilter(cfg.ModelFilter),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2125,6 +2186,73 @@ func normalizeKeywordBlockingMode(mode string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeContentModerationModelFilter(filter ContentModerationModelFilter) ContentModerationModelFilter {
|
||||||
|
out := ContentModerationModelFilter{
|
||||||
|
Type: normalizeContentModerationModelFilterType(filter.Type),
|
||||||
|
Models: normalizeContentModerationModelNames(filter.Models),
|
||||||
|
}
|
||||||
|
if out.Type == ContentModerationModelFilterAll {
|
||||||
|
out.Models = []string{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneContentModerationModelFilter(filter ContentModerationModelFilter) ContentModerationModelFilter {
|
||||||
|
normalized := normalizeContentModerationModelFilter(filter)
|
||||||
|
normalized.Models = append([]string(nil), normalized.Models...)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeContentModerationModelFilterType(filterType string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(filterType)) {
|
||||||
|
case ContentModerationModelFilterInclude:
|
||||||
|
return ContentModerationModelFilterInclude
|
||||||
|
case ContentModerationModelFilterExclude:
|
||||||
|
return ContentModerationModelFilterExclude
|
||||||
|
case ContentModerationModelFilterAll:
|
||||||
|
return ContentModerationModelFilterAll
|
||||||
|
default:
|
||||||
|
return ContentModerationModelFilterAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeContentModerationModelNames(models []string) []string {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(models))
|
||||||
|
seen := make(map[string]struct{}, len(models))
|
||||||
|
for _, raw := range models {
|
||||||
|
model := trimRunes(strings.TrimSpace(raw), maxContentModerationModelFilterRunes)
|
||||||
|
if model == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(model)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, model)
|
||||||
|
if len(out) >= maxContentModerationModelFilterModels {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentModerationModelListContains(models []string, model string) bool {
|
||||||
|
model = strings.ToLower(strings.TrimSpace(model))
|
||||||
|
if model == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, candidate := range models {
|
||||||
|
if strings.ToLower(strings.TrimSpace(candidate)) == model {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func matchBlockedKeyword(text string, keywords []string) (string, bool) {
|
func matchBlockedKeyword(text string, keywords []string) (string, bool) {
|
||||||
if text == "" || len(keywords) == 0 {
|
if text == "" || len(keywords) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@ -530,6 +530,147 @@ func TestNormalizeKeywordBlockingMode_UnknownFallsBackToDefault(t *testing.T) {
|
|||||||
require.Equal(t, ContentModerationKeywordModeAPIOnly, normalizeKeywordBlockingMode("api_only"))
|
require.Equal(t, ContentModerationKeywordModeAPIOnly, normalizeKeywordBlockingMode("api_only"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContentModerationCheck_ModelFilterAllAuditsEveryModel(t *testing.T) {
|
||||||
|
cfg := defaultContentModerationModelFilterTestConfig()
|
||||||
|
cfg.ModelFilter = ContentModerationModelFilter{Type: ContentModerationModelFilterAll}
|
||||||
|
svc, repo := newContentModerationModelFilterTestService(t, cfg)
|
||||||
|
|
||||||
|
for _, model := range []string{"gpt-5.5", "gpt-5.4"} {
|
||||||
|
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
Model: model,
|
||||||
|
Protocol: ContentModerationProtocolOpenAIChat,
|
||||||
|
Body: []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Blocked)
|
||||||
|
require.Equal(t, ContentModerationActionKeywordBlock, decision.Action)
|
||||||
|
}
|
||||||
|
require.Len(t, repo.logs, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationCheck_ModelFilterIncludeOnlyAuditsListedModels(t *testing.T) {
|
||||||
|
cfg := defaultContentModerationModelFilterTestConfig()
|
||||||
|
cfg.ModelFilter = ContentModerationModelFilter{Type: ContentModerationModelFilterInclude, Models: []string{"gpt-5.5"}}
|
||||||
|
svc, repo := newContentModerationModelFilterTestService(t, cfg)
|
||||||
|
|
||||||
|
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
Model: "gpt-5.5",
|
||||||
|
Protocol: ContentModerationProtocolOpenAIChat,
|
||||||
|
Body: []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Blocked)
|
||||||
|
require.Equal(t, ContentModerationActionKeywordBlock, decision.Action)
|
||||||
|
|
||||||
|
decision, err = svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
Model: "gpt-5.4",
|
||||||
|
Protocol: ContentModerationProtocolOpenAIChat,
|
||||||
|
Body: []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Allowed)
|
||||||
|
require.False(t, decision.Blocked)
|
||||||
|
require.Equal(t, ContentModerationActionAllow, decision.Action)
|
||||||
|
require.Len(t, repo.logs, 1)
|
||||||
|
require.Equal(t, "gpt-5.5", repo.logs[0].Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationCheck_ModelFilterExcludeSkipsListedModels(t *testing.T) {
|
||||||
|
cfg := defaultContentModerationModelFilterTestConfig()
|
||||||
|
cfg.ModelFilter = ContentModerationModelFilter{Type: ContentModerationModelFilterExclude, Models: []string{"gpt-5.4"}}
|
||||||
|
svc, repo := newContentModerationModelFilterTestService(t, cfg)
|
||||||
|
|
||||||
|
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
Model: "gpt-5.5",
|
||||||
|
Protocol: ContentModerationProtocolOpenAIChat,
|
||||||
|
Body: []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Blocked)
|
||||||
|
require.Equal(t, ContentModerationActionKeywordBlock, decision.Action)
|
||||||
|
|
||||||
|
decision, err = svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
Model: "gpt-5.4",
|
||||||
|
Protocol: ContentModerationProtocolOpenAIChat,
|
||||||
|
Body: []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Allowed)
|
||||||
|
require.False(t, decision.Blocked)
|
||||||
|
require.Equal(t, ContentModerationActionAllow, decision.Action)
|
||||||
|
require.Len(t, repo.logs, 1)
|
||||||
|
require.Equal(t, "gpt-5.5", repo.logs[0].Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationLoadConfig_LegacyConfigDefaultsModelFilterToAll(t *testing.T) {
|
||||||
|
raw := `{"enabled":true,"mode":"pre_block","base_url":"https://api.openai.com","model":"omni-moderation-latest","blocked_keywords":["secret-token"]}`
|
||||||
|
svc := NewContentModerationService(
|
||||||
|
&contentModerationTestSettingRepo{values: map[string]string{
|
||||||
|
SettingKeyContentModerationConfig: raw,
|
||||||
|
}},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg, err := svc.loadConfig(context.Background())
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, ContentModerationModelFilterAll, cfg.ModelFilter.Type)
|
||||||
|
require.Empty(t, cfg.ModelFilter.Models)
|
||||||
|
require.True(t, cfg.includesModel("gpt-5.5"))
|
||||||
|
require.True(t, cfg.includesModel("gpt-5.4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentModerationCheck_ModelFilterUsesRequestedModelNotBodyModel(t *testing.T) {
|
||||||
|
cfg := defaultContentModerationModelFilterTestConfig()
|
||||||
|
cfg.ModelFilter = ContentModerationModelFilter{Type: ContentModerationModelFilterInclude, Models: []string{"gpt-5.5"}}
|
||||||
|
svc, repo := newContentModerationModelFilterTestService(t, cfg)
|
||||||
|
|
||||||
|
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||||
|
Model: "gpt-5.5",
|
||||||
|
Protocol: ContentModerationProtocolOpenAIChat,
|
||||||
|
Body: []byte(`{"model":"mapped-upstream-model","messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, decision.Blocked)
|
||||||
|
require.Equal(t, ContentModerationActionKeywordBlock, decision.Action)
|
||||||
|
require.Len(t, repo.logs, 1)
|
||||||
|
require.Equal(t, "gpt-5.5", repo.logs[0].Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultContentModerationModelFilterTestConfig() *ContentModerationConfig {
|
||||||
|
cfg := defaultContentModerationConfig()
|
||||||
|
cfg.Enabled = true
|
||||||
|
cfg.Mode = ContentModerationModePreBlock
|
||||||
|
cfg.BlockedKeywords = []string{"secret-token"}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func newContentModerationModelFilterTestService(t *testing.T, cfg *ContentModerationConfig) (*ContentModerationService, *contentModerationTestRepo) {
|
||||||
|
t.Helper()
|
||||||
|
rawCfg, err := json.Marshal(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
repo := &contentModerationTestRepo{}
|
||||||
|
svc := NewContentModerationService(
|
||||||
|
&contentModerationTestSettingRepo{values: map[string]string{
|
||||||
|
SettingKeyRiskControlEnabled: "true",
|
||||||
|
SettingKeyContentModerationConfig: string(rawCfg),
|
||||||
|
}},
|
||||||
|
repo,
|
||||||
|
&contentModerationTestHashCache{},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
return svc, repo
|
||||||
|
}
|
||||||
|
|
||||||
func TestContentModerationUpdateConfig_AppendsAndDeletesAPIKeys(t *testing.T) {
|
func TestContentModerationUpdateConfig_AppendsAndDeletesAPIKeys(t *testing.T) {
|
||||||
cfg := defaultContentModerationConfig()
|
cfg := defaultContentModerationConfig()
|
||||||
cfg.APIKeys = []string{"sk-old-a", "sk-old-b"}
|
cfg.APIKeys = []string{"sk-old-a", "sk-old-b"}
|
||||||
|
|||||||
@ -2,6 +2,12 @@ import { apiClient } from '../client'
|
|||||||
|
|
||||||
export type ModerationMode = 'off' | 'observe' | 'pre_block'
|
export type ModerationMode = 'off' | 'observe' | 'pre_block'
|
||||||
export type KeywordBlockingMode = 'keyword_only' | 'keyword_and_api' | 'api_only'
|
export type KeywordBlockingMode = 'keyword_only' | 'keyword_and_api' | 'api_only'
|
||||||
|
export type ContentModerationModelFilterType = 'all' | 'include' | 'exclude'
|
||||||
|
|
||||||
|
export interface ContentModerationModelFilter {
|
||||||
|
type: ContentModerationModelFilterType
|
||||||
|
models: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContentModerationConfig {
|
export interface ContentModerationConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@ -32,6 +38,7 @@ export interface ContentModerationConfig {
|
|||||||
pre_hash_check_enabled: boolean
|
pre_hash_check_enabled: boolean
|
||||||
blocked_keywords: string[]
|
blocked_keywords: string[]
|
||||||
keyword_blocking_mode: KeywordBlockingMode
|
keyword_blocking_mode: KeywordBlockingMode
|
||||||
|
model_filter: ContentModerationModelFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen'
|
export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen'
|
||||||
@ -105,6 +112,7 @@ export interface UpdateContentModerationConfig {
|
|||||||
pre_hash_check_enabled?: boolean
|
pre_hash_check_enabled?: boolean
|
||||||
blocked_keywords?: string[]
|
blocked_keywords?: string[]
|
||||||
keyword_blocking_mode?: KeywordBlockingMode
|
keyword_blocking_mode?: KeywordBlockingMode
|
||||||
|
model_filter?: ContentModerationModelFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentModerationRuntimeStatus {
|
export interface ContentModerationRuntimeStatus {
|
||||||
|
|||||||
@ -2533,6 +2533,20 @@ export default {
|
|||||||
selectedGroups: 'Selected Groups',
|
selectedGroups: 'Selected Groups',
|
||||||
searchGroups: 'Search group name or platform',
|
searchGroups: 'Search group name or platform',
|
||||||
noGroups: 'No groups available',
|
noGroups: 'No groups available',
|
||||||
|
modelFilter: 'Model scope',
|
||||||
|
modelFilterHint: 'Moderate by the client-requested model name; channel model mappings do not change this match.',
|
||||||
|
modelFilterAll: 'All models',
|
||||||
|
modelFilterAllDesc: 'All model requests go through content moderation.',
|
||||||
|
modelFilterInclude: 'Only selected',
|
||||||
|
modelFilterIncludeDesc: 'Only listed models go through content moderation.',
|
||||||
|
modelFilterExclude: 'Exclude selected',
|
||||||
|
modelFilterExcludeDesc: 'Listed models skip content moderation; other models are moderated.',
|
||||||
|
modelFilterModels: 'Model list',
|
||||||
|
modelFilterModelCount: '{count} models configured',
|
||||||
|
modelFilterModelsRequired: 'This model scope requires at least 1 model',
|
||||||
|
modelFilterAllSummary: 'Applies to all models',
|
||||||
|
modelFilterIncludeSummary: 'Applies to {count} models',
|
||||||
|
modelFilterExcludeSummary: 'Excludes {count} models',
|
||||||
emptyLogs: 'No audit records',
|
emptyLogs: 'No audit records',
|
||||||
workerStatus: 'Worker Runtime',
|
workerStatus: 'Worker Runtime',
|
||||||
workerStatusHint: 'Queue and worker pool status for asynchronous observation tasks.',
|
workerStatusHint: 'Queue and worker pool status for asynchronous observation tasks.',
|
||||||
|
|||||||
@ -2610,6 +2610,20 @@ export default {
|
|||||||
selectedGroups: '指定分组',
|
selectedGroups: '指定分组',
|
||||||
searchGroups: '搜索分组名称或平台',
|
searchGroups: '搜索分组名称或平台',
|
||||||
noGroups: '暂无可用分组',
|
noGroups: '暂无可用分组',
|
||||||
|
modelFilter: '模型范围',
|
||||||
|
modelFilterHint: '按客户端请求的模型名决定是否执行内容审计,模型映射后仍以请求模型判断。',
|
||||||
|
modelFilterAll: '所有模型',
|
||||||
|
modelFilterAllDesc: '所有模型请求都会进入内容审计。',
|
||||||
|
modelFilterInclude: '仅指定模型',
|
||||||
|
modelFilterIncludeDesc: '只有列表中的模型会执行内容审计。',
|
||||||
|
modelFilterExclude: '排除指定模型',
|
||||||
|
modelFilterExcludeDesc: '列表中的模型跳过内容审计,其余模型执行审计。',
|
||||||
|
modelFilterModels: '模型列表',
|
||||||
|
modelFilterModelCount: '已配置 {count} 个模型',
|
||||||
|
modelFilterModelsRequired: '当前模型范围至少需要配置 1 个模型',
|
||||||
|
modelFilterAllSummary: '全部模型生效',
|
||||||
|
modelFilterIncludeSummary: '仅 {count} 个模型生效',
|
||||||
|
modelFilterExcludeSummary: '排除 {count} 个模型',
|
||||||
emptyLogs: '暂无审核记录',
|
emptyLogs: '暂无审核记录',
|
||||||
workerStatus: 'Worker 运行状态',
|
workerStatus: 'Worker 运行状态',
|
||||||
workerStatusHint: '异步观察任务的队列和 worker 池状态。',
|
workerStatusHint: '异步观察任务的队列和 worker 池状态。',
|
||||||
|
|||||||
@ -145,6 +145,26 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900/30 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="flex min-w-0 items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
<Icon name="filter" size="sm" class="flex-shrink-0 text-gray-400" />
|
||||||
|
<span class="font-medium">{{ t('admin.riskControl.modelFilter') }}</span>
|
||||||
|
<span class="truncate text-gray-500 dark:text-gray-400">{{ modelFilterSummary }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="modelFilterPreviewModels.length > 0" class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="model in modelFilterPreviewModels"
|
||||||
|
:key="model"
|
||||||
|
class="inline-flex max-w-[180px] items-center truncate rounded-md bg-white px-2 py-1 font-mono text-xs text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ model }}
|
||||||
|
</span>
|
||||||
|
<span v-if="hiddenModelFilterModelCount > 0" class="inline-flex rounded-md bg-white px-2 py-1 text-xs text-gray-500 shadow-sm dark:bg-dark-800 dark:text-gray-400">
|
||||||
|
+{{ hiddenModelFilterModelCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-6">
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||||
<Select v-model="filters.result" :options="resultOptions" @change="reloadLogsFromFirstPage" />
|
<Select v-model="filters.result" :options="resultOptions" @change="reloadLogsFromFirstPage" />
|
||||||
<Select v-model="filters.group_id" :options="groupFilterOptions" @change="reloadLogsFromFirstPage" />
|
<Select v-model="filters.group_id" :options="groupFilterOptions" @change="reloadLogsFromFirstPage" />
|
||||||
@ -628,6 +648,52 @@
|
|||||||
<p v-if="filteredGroups.length === 0" class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.noGroups') }}</p>
|
<p v-if="filteredGroups.length === 0" class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.noGroups') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 rounded-lg border border-gray-100 p-4 dark:border-dark-700">
|
||||||
|
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.modelFilter') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.modelFilterHint') }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex w-fit rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:bg-dark-700 dark:text-gray-300">
|
||||||
|
{{ modelFilterSummary }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||||
|
<button
|
||||||
|
v-for="option in modelFilterOptions"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border p-3 text-left transition-colors"
|
||||||
|
:class="configForm.model_filter_type === option.value
|
||||||
|
? 'border-primary-300 bg-primary-50 text-primary-900 shadow-sm dark:border-primary-700 dark:bg-primary-900/20 dark:text-primary-100'
|
||||||
|
: 'border-gray-100 hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-700/60'"
|
||||||
|
@click="setModelFilterType(option.value)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm font-semibold">{{ option.label }}</span>
|
||||||
|
<span
|
||||||
|
class="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border"
|
||||||
|
:class="configForm.model_filter_type === option.value
|
||||||
|
? 'border-primary-500 bg-primary-500 text-white'
|
||||||
|
: 'border-gray-300 text-transparent dark:border-dark-500'"
|
||||||
|
>
|
||||||
|
<Icon name="check" size="xs" :stroke-width="2" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ option.description }}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="configForm.model_filter_type !== 'all'" class="space-y-2">
|
||||||
|
<label class="input-label">{{ t('admin.riskControl.modelFilterModels') }}</label>
|
||||||
|
<ModelWhitelistSelector v-model="configForm.model_filter_models" />
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.riskControl.modelFilterModelCount', { count: modelFilterModelCount }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeSettingsTab === 'runtime'" class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
<div v-else-if="activeSettingsTab === 'runtime'" class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||||
@ -887,11 +953,14 @@ import Icon from '@/components/icons/Icon.vue'
|
|||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import Toggle from '@/components/common/Toggle.vue'
|
import Toggle from '@/components/common/Toggle.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
|
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type {
|
import type {
|
||||||
ContentModerationAPIKeyStatus,
|
ContentModerationAPIKeyStatus,
|
||||||
ContentModerationConfig,
|
ContentModerationConfig,
|
||||||
ContentModerationLog,
|
ContentModerationLog,
|
||||||
|
ContentModerationModelFilter,
|
||||||
|
ContentModerationModelFilterType,
|
||||||
ContentModerationRuntimeStatus,
|
ContentModerationRuntimeStatus,
|
||||||
ContentModerationTestAuditResult,
|
ContentModerationTestAuditResult,
|
||||||
KeywordBlockingMode,
|
KeywordBlockingMode,
|
||||||
@ -987,6 +1056,8 @@ const configForm = reactive({
|
|||||||
pre_hash_check_enabled: false,
|
pre_hash_check_enabled: false,
|
||||||
blocked_keywords_text: '',
|
blocked_keywords_text: '',
|
||||||
keyword_blocking_mode: 'keyword_and_api' as KeywordBlockingMode,
|
keyword_blocking_mode: 'keyword_and_api' as KeywordBlockingMode,
|
||||||
|
model_filter_type: 'all' as ContentModerationModelFilterType,
|
||||||
|
model_filter_models: [] as string[],
|
||||||
})
|
})
|
||||||
|
|
||||||
const pagination = reactive({
|
const pagination = reactive({
|
||||||
@ -1038,6 +1109,24 @@ const keywordBlockingModeOptions = computed<Array<{ value: KeywordBlockingMode;
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const modelFilterOptions = computed<Array<{ value: ContentModerationModelFilterType; label: string; description: string }>>(() => [
|
||||||
|
{
|
||||||
|
value: 'all',
|
||||||
|
label: t('admin.riskControl.modelFilterAll'),
|
||||||
|
description: t('admin.riskControl.modelFilterAllDesc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'include',
|
||||||
|
label: t('admin.riskControl.modelFilterInclude'),
|
||||||
|
description: t('admin.riskControl.modelFilterIncludeDesc'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'exclude',
|
||||||
|
label: t('admin.riskControl.modelFilterExclude'),
|
||||||
|
description: t('admin.riskControl.modelFilterExcludeDesc'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
type KeywordNoticeView = {
|
type KeywordNoticeView = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@ -1120,6 +1209,22 @@ const groupFilterOptions = computed<SelectOption[]>(() => [
|
|||||||
|
|
||||||
const selectedGroupCount = computed(() => String(configForm.group_ids.length))
|
const selectedGroupCount = computed(() => String(configForm.group_ids.length))
|
||||||
|
|
||||||
|
const modelFilterModelCount = computed(() => configForm.model_filter_models.length)
|
||||||
|
|
||||||
|
const modelFilterSummary = computed(() => {
|
||||||
|
if (configForm.model_filter_type === 'include') {
|
||||||
|
return t('admin.riskControl.modelFilterIncludeSummary', { count: modelFilterModelCount.value })
|
||||||
|
}
|
||||||
|
if (configForm.model_filter_type === 'exclude') {
|
||||||
|
return t('admin.riskControl.modelFilterExcludeSummary', { count: modelFilterModelCount.value })
|
||||||
|
}
|
||||||
|
return t('admin.riskControl.modelFilterAllSummary')
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelFilterPreviewModels = computed(() => configForm.model_filter_models.slice(0, 6))
|
||||||
|
|
||||||
|
const hiddenModelFilterModelCount = computed(() => Math.max(0, configForm.model_filter_models.length - modelFilterPreviewModels.value.length))
|
||||||
|
|
||||||
const filteredGroups = computed(() => {
|
const filteredGroups = computed(() => {
|
||||||
const keyword = groupSearch.value.trim().toLowerCase()
|
const keyword = groupSearch.value.trim().toLowerCase()
|
||||||
if (!keyword) return groups.value
|
if (!keyword) return groups.value
|
||||||
@ -1238,7 +1343,7 @@ const overviewItems = computed<OverviewItem[]>(() => [
|
|||||||
key: 'scope',
|
key: 'scope',
|
||||||
label: t('admin.riskControl.overview.groupScope'),
|
label: t('admin.riskControl.overview.groupScope'),
|
||||||
value: configForm.all_groups ? t('admin.riskControl.allGroups') : selectedGroupCount.value,
|
value: configForm.all_groups ? t('admin.riskControl.allGroups') : selectedGroupCount.value,
|
||||||
meta: configForm.all_groups ? t('admin.riskControl.allGroupsHint') : t('admin.riskControl.selectedGroupsHint'),
|
meta: modelFilterSummary.value,
|
||||||
icon: 'users',
|
icon: 'users',
|
||||||
iconClass: 'bg-violet-50 text-violet-600 dark:bg-violet-900/20 dark:text-violet-300',
|
iconClass: 'bg-violet-50 text-violet-600 dark:bg-violet-900/20 dark:text-violet-300',
|
||||||
},
|
},
|
||||||
@ -1342,6 +1447,9 @@ function applyConfig(config: ContentModerationConfig) {
|
|||||||
configForm.pre_hash_check_enabled = config.pre_hash_check_enabled ?? false
|
configForm.pre_hash_check_enabled = config.pre_hash_check_enabled ?? false
|
||||||
configForm.blocked_keywords_text = Array.isArray(config.blocked_keywords) ? config.blocked_keywords.join('\n') : ''
|
configForm.blocked_keywords_text = Array.isArray(config.blocked_keywords) ? config.blocked_keywords.join('\n') : ''
|
||||||
configForm.keyword_blocking_mode = normalizeKeywordBlockingMode(config.keyword_blocking_mode)
|
configForm.keyword_blocking_mode = normalizeKeywordBlockingMode(config.keyword_blocking_mode)
|
||||||
|
const modelFilter = normalizeModelFilter(config.model_filter)
|
||||||
|
configForm.model_filter_type = modelFilter.type
|
||||||
|
configForm.model_filter_models = modelFilter.models
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
@ -1388,6 +1496,11 @@ async function loadStatus(silent = true) {
|
|||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
const modelFilterPayload = buildModelFilterPayload()
|
||||||
|
if (modelFilterPayload.type !== 'all' && modelFilterPayload.models.length === 0) {
|
||||||
|
appStore.showError(t('admin.riskControl.modelFilterModelsRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload: UpdateContentModerationConfig = {
|
const payload: UpdateContentModerationConfig = {
|
||||||
enabled: configForm.enabled,
|
enabled: configForm.enabled,
|
||||||
mode: configForm.mode,
|
mode: configForm.mode,
|
||||||
@ -1413,6 +1526,7 @@ async function saveConfig() {
|
|||||||
pre_hash_check_enabled: configForm.pre_hash_check_enabled,
|
pre_hash_check_enabled: configForm.pre_hash_check_enabled,
|
||||||
blocked_keywords: blockedKeywordList.value,
|
blocked_keywords: blockedKeywordList.value,
|
||||||
keyword_blocking_mode: configForm.keyword_blocking_mode,
|
keyword_blocking_mode: configForm.keyword_blocking_mode,
|
||||||
|
model_filter: modelFilterPayload,
|
||||||
}
|
}
|
||||||
const keys = parseApiKeys(configForm.api_keys_text)
|
const keys = parseApiKeys(configForm.api_keys_text)
|
||||||
if (!payload.clear_api_key && configForm.api_keys_mode === 'replace' && keys.length === 0) {
|
if (!payload.clear_api_key && configForm.api_keys_mode === 'replace' && keys.length === 0) {
|
||||||
@ -1568,6 +1682,13 @@ function setAPIKeysMode(mode: APIKeysWriteMode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setModelFilterType(type: ContentModerationModelFilterType) {
|
||||||
|
configForm.model_filter_type = type
|
||||||
|
if (type === 'all') {
|
||||||
|
configForm.model_filter_models = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function testApiKeys(useInputKeys: boolean) {
|
async function testApiKeys(useInputKeys: boolean) {
|
||||||
const keys = useInputKeys ? parseApiKeys(configForm.api_keys_text) : []
|
const keys = useInputKeys ? parseApiKeys(configForm.api_keys_text) : []
|
||||||
if (useInputKeys && keys.length === 0) {
|
if (useInputKeys && keys.length === 0) {
|
||||||
@ -1824,6 +1945,49 @@ function normalizeKeywordBlockingMode(value: unknown): KeywordBlockingMode {
|
|||||||
return 'keyword_and_api'
|
return 'keyword_and_api'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeModelFilter(value: unknown): ContentModerationModelFilter {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return { type: 'all', models: [] }
|
||||||
|
}
|
||||||
|
const raw = value as Partial<ContentModerationModelFilter>
|
||||||
|
const type = normalizeModelFilterType(raw.type)
|
||||||
|
const models = type === 'all' ? [] : normalizeModelNames(raw.models)
|
||||||
|
return { type, models }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelFilterType(value: unknown): ContentModerationModelFilterType {
|
||||||
|
if (value === 'include' || value === 'exclude' || value === 'all') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelNames(models: unknown): string[] {
|
||||||
|
if (!Array.isArray(models)) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const item of models) {
|
||||||
|
const model = String(item ?? '').trim()
|
||||||
|
if (!model) continue
|
||||||
|
const key = model.toLowerCase()
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(model)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelFilterPayload(): ContentModerationModelFilter {
|
||||||
|
const type = normalizeModelFilterType(configForm.model_filter_type)
|
||||||
|
if (type === 'all') {
|
||||||
|
return { type: 'all', models: [] }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
models: normalizeModelNames(configForm.model_filter_models),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseBlockedKeywords(value: string): string[] {
|
function parseBlockedKeywords(value: string): string[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
|
|||||||
227
frontend/src/views/admin/__tests__/RiskControlView.spec.ts
Normal file
227
frontend/src/views/admin/__tests__/RiskControlView.spec.ts
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent, h } from 'vue'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
|
||||||
|
|
||||||
|
import RiskControlView from '../RiskControlView.vue'
|
||||||
|
import type { ContentModerationConfig, UpdateContentModerationConfig } from '@/api/admin/riskControl'
|
||||||
|
|
||||||
|
const {
|
||||||
|
getConfig,
|
||||||
|
updateConfig,
|
||||||
|
getStatus,
|
||||||
|
listLogs,
|
||||||
|
getGroups,
|
||||||
|
showError,
|
||||||
|
showSuccess,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
getConfig: vi.fn(),
|
||||||
|
updateConfig: vi.fn(),
|
||||||
|
getStatus: vi.fn(),
|
||||||
|
listLogs: vi.fn(),
|
||||||
|
getGroups: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/admin', () => ({
|
||||||
|
adminAPI: {
|
||||||
|
riskControl: {
|
||||||
|
getConfig,
|
||||||
|
updateConfig,
|
||||||
|
getStatus,
|
||||||
|
listLogs,
|
||||||
|
testAPIKeys: vi.fn(),
|
||||||
|
deleteFlaggedHash: vi.fn(),
|
||||||
|
clearFlaggedHashes: vi.fn(),
|
||||||
|
unbanUser: vi.fn(),
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
getAll: getGroups,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({
|
||||||
|
showError,
|
||||||
|
showSuccess,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/apiError', () => ({
|
||||||
|
extractApiErrorMessage: (_err: unknown, fallback: string) => fallback,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string, params?: Record<string, string | number>) =>
|
||||||
|
key.replace(/\{(\w+)\}/g, (_, token) => String(params?.[token] ?? `{${token}}`)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseConfig = (): ContentModerationConfig => ({
|
||||||
|
enabled: true,
|
||||||
|
mode: 'pre_block',
|
||||||
|
base_url: 'https://api.openai.com',
|
||||||
|
model: 'omni-moderation-latest',
|
||||||
|
api_key_configured: false,
|
||||||
|
api_key_masked: '',
|
||||||
|
api_key_count: 0,
|
||||||
|
api_key_masks: [],
|
||||||
|
api_key_statuses: [],
|
||||||
|
timeout_ms: 3000,
|
||||||
|
sample_rate: 100,
|
||||||
|
all_groups: true,
|
||||||
|
group_ids: [],
|
||||||
|
record_non_hits: false,
|
||||||
|
worker_count: 4,
|
||||||
|
queue_size: 32768,
|
||||||
|
block_status: 403,
|
||||||
|
block_message: '内容审计命中风险规则,请调整输入后重试',
|
||||||
|
email_on_hit: true,
|
||||||
|
auto_ban_enabled: true,
|
||||||
|
ban_threshold: 10,
|
||||||
|
violation_window_hours: 720,
|
||||||
|
retry_count: 2,
|
||||||
|
hit_retention_days: 180,
|
||||||
|
non_hit_retention_days: 3,
|
||||||
|
pre_hash_check_enabled: false,
|
||||||
|
blocked_keywords: [],
|
||||||
|
keyword_blocking_mode: 'keyword_and_api',
|
||||||
|
model_filter: {
|
||||||
|
type: 'all',
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const runtimeStatus = () => ({
|
||||||
|
enabled: true,
|
||||||
|
risk_control_enabled: true,
|
||||||
|
mode: 'pre_block',
|
||||||
|
worker_count: 4,
|
||||||
|
max_workers: 32,
|
||||||
|
active_workers: 0,
|
||||||
|
idle_workers: 4,
|
||||||
|
queue_size: 32768,
|
||||||
|
queue_length: 0,
|
||||||
|
queue_usage_percent: 0,
|
||||||
|
enqueued: 0,
|
||||||
|
dropped: 0,
|
||||||
|
processed: 0,
|
||||||
|
errors: 0,
|
||||||
|
api_key_statuses: [],
|
||||||
|
flagged_hash_count: 0,
|
||||||
|
last_cleanup_deleted_hit: 0,
|
||||||
|
last_cleanup_deleted_non_hit: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const AppLayoutStub = { template: '<div><slot /></div>' }
|
||||||
|
const BaseDialogStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
|
||||||
|
})
|
||||||
|
const ModelWhitelistSelectorStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const value = (event.target as HTMLInputElement).value
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
value
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return () =>
|
||||||
|
h('input', {
|
||||||
|
'data-test': 'model-filter-input',
|
||||||
|
value: (props.modelValue as string[]).join('\n'),
|
||||||
|
onInput,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function findButtonByText(wrapper: VueWrapper, text: string): DOMWrapper<HTMLButtonElement> {
|
||||||
|
const button = wrapper.findAll<HTMLButtonElement>('button').find((item) => item.text().includes(text))
|
||||||
|
if (!button) {
|
||||||
|
throw new Error(`button not found: ${text}`)
|
||||||
|
}
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin RiskControlView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getConfig.mockReset()
|
||||||
|
updateConfig.mockReset()
|
||||||
|
getStatus.mockReset()
|
||||||
|
listLogs.mockReset()
|
||||||
|
getGroups.mockReset()
|
||||||
|
showError.mockReset()
|
||||||
|
showSuccess.mockReset()
|
||||||
|
|
||||||
|
getConfig.mockResolvedValue(baseConfig())
|
||||||
|
getStatus.mockResolvedValue(runtimeStatus())
|
||||||
|
listLogs.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 1 })
|
||||||
|
getGroups.mockResolvedValue([])
|
||||||
|
updateConfig.mockImplementation(async (payload: UpdateContentModerationConfig) => ({
|
||||||
|
...baseConfig(),
|
||||||
|
...payload,
|
||||||
|
model_filter: payload.model_filter ?? baseConfig().model_filter,
|
||||||
|
api_key_configured: false,
|
||||||
|
api_key_masked: '',
|
||||||
|
api_key_count: 0,
|
||||||
|
api_key_masks: [],
|
||||||
|
api_key_statuses: [],
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves the selected model filter mode and models', async () => {
|
||||||
|
const wrapper = mount(RiskControlView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AppLayout: AppLayoutStub,
|
||||||
|
BaseDialog: BaseDialogStub,
|
||||||
|
Icon: true,
|
||||||
|
Select: true,
|
||||||
|
Toggle: true,
|
||||||
|
Pagination: true,
|
||||||
|
ModelWhitelistSelector: ModelWhitelistSelectorStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
|
||||||
|
await findButtonByText(wrapper, 'admin.riskControl.tabs.scope').trigger('click')
|
||||||
|
await findButtonByText(wrapper, 'admin.riskControl.modelFilterInclude').trigger('click')
|
||||||
|
await wrapper.get('[data-test="model-filter-input"]').setValue('gpt-5.5, gpt-5.4')
|
||||||
|
await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
model_filter: {
|
||||||
|
type: 'include',
|
||||||
|
models: ['gpt-5.5', 'gpt-5.4'],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
expect(showError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user