fix moderation key handling and key UI

This commit is contained in:
shaw
2026-05-07 14:31:19 +08:00
parent f3577bc69c
commit 0eca600ffa
9 changed files with 500 additions and 72 deletions

View File

@@ -26,6 +26,8 @@ type contentModerationConfigRequest struct {
Model *string `json:"model"`
APIKey *string `json:"api_key"`
APIKeys *[]string `json:"api_keys"`
APIKeysMode string `json:"api_keys_mode"`
DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
ClearAPIKey bool `json:"clear_api_key"`
TimeoutMS *int `json:"timeout_ms"`
SampleRate *int `json:"sample_rate"`
@@ -81,6 +83,8 @@ func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
Model: req.Model,
APIKey: req.APIKey,
APIKeys: req.APIKeys,
APIKeysMode: req.APIKeysMode,
DeleteAPIKeyHashes: req.DeleteAPIKeyHashes,
ClearAPIKey: req.ClearAPIKey,
TimeoutMS: req.TimeoutMS,
SampleRate: req.SampleRate,

View File

@@ -29,6 +29,9 @@ const (
ContentModerationModeObserve = "observe"
ContentModerationModePreBlock = "pre_block"
contentModerationAPIKeysModeAppend = "append"
contentModerationAPIKeysModeReplace = "replace"
ContentModerationActionAllow = "allow"
ContentModerationActionBlock = "block"
ContentModerationActionHashBlock = "hash_block"
@@ -61,9 +64,11 @@ const (
defaultContentModerationNonHitRetentionDays = 3
maxContentModerationRetentionDays = 3650
maxContentModerationNonHitRetentionDays = 3
contentModerationKeyFailureFreezeThreshold = 3
contentModerationKeyFreezeDuration = time.Minute
maxContentModerationTestImages = 4
contentModerationKeyRateLimitFreezeDuration = time.Minute
contentModerationKeyAuthFreezeDuration = 10 * time.Minute
contentModerationKeyHTTPErrorFreezeDuration = 10 * time.Second
maxContentModerationInputImages = 1
maxContentModerationTestImages = maxContentModerationInputImages
maxContentModerationTestImageBytes = 8 * 1024 * 1024
maxContentModerationTestImageDataURLBytes = 12 * 1024 * 1024
@@ -215,6 +220,8 @@ type UpdateContentModerationConfigInput struct {
Model *string `json:"model"`
APIKey *string `json:"api_key"`
APIKeys *[]string `json:"api_keys"`
APIKeysMode string `json:"api_keys_mode"`
DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
ClearAPIKey bool `json:"clear_api_key"`
TimeoutMS *int `json:"timeout_ms"`
SampleRate *int `json:"sample_rate"`
@@ -260,7 +267,7 @@ func (in *ContentModerationInput) Normalize() {
return
}
in.Text = trimRunes(normalizeContentModerationText(in.Text), maxModerationInputRunes)
in.Images = normalizeModerationImages(in.Images)
in.Images = limitContentModerationImages(normalizeModerationImages(in.Images))
}
func (in ContentModerationInput) IsEmpty() bool {
@@ -268,14 +275,15 @@ func (in ContentModerationInput) IsEmpty() bool {
}
func (in ContentModerationInput) ModerationInput() any {
if len(in.Images) == 0 {
images := limitContentModerationImages(in.Images)
if len(images) == 0 {
return in.Text
}
parts := make([]moderationAPIInputPart, 0, len(in.Images)+1)
parts := make([]moderationAPIInputPart, 0, len(images)+1)
if strings.TrimSpace(in.Text) != "" {
parts = append(parts, moderationAPIInputPart{Type: "text", Text: in.Text})
}
for _, image := range in.Images {
for _, image := range images {
parts = append(parts, moderationAPIInputPart{
Type: "image_url",
ImageURL: &moderationAPIImageURLRef{URL: image},
@@ -565,8 +573,17 @@ func (s *ContentModerationService) UpdateConfig(ctx context.Context, input Updat
cfg.APIKey = ""
cfg.APIKeys = []string{}
} else {
apiKeysMode := normalizeContentModerationAPIKeysMode(input.APIKeysMode)
if input.DeleteAPIKeyHashes != nil && apiKeysMode != contentModerationAPIKeysModeReplace {
cfg.APIKeys = deleteModerationAPIKeysByHash(cfg.apiKeys(), *input.DeleteAPIKeyHashes)
cfg.APIKey = ""
}
if input.APIKeys != nil {
cfg.APIKeys = normalizeModerationAPIKeys(*input.APIKeys)
if apiKeysMode == contentModerationAPIKeysModeReplace {
cfg.APIKeys = normalizeModerationAPIKeys(*input.APIKeys)
} else {
cfg.APIKeys = normalizeModerationAPIKeys(append(cfg.apiKeys(), *input.APIKeys...))
}
cfg.APIKey = ""
}
if input.APIKey != nil && strings.TrimSpace(*input.APIKey) != "" {
@@ -636,7 +653,7 @@ func (s *ContentModerationService) TestAPIKeys(ctx context.Context, input TestCo
latency := int(time.Since(start).Milliseconds())
keyHash := moderationAPIKeyHash(key)
if err != nil {
s.markAPIKeyFailure(key, err.Error(), latency, httpStatus)
s.markAPIKeyError(key, err.Error(), latency, httpStatus)
} else {
s.markAPIKeySuccess(key, latency, httpStatus)
if auditResult == nil {
@@ -1227,8 +1244,11 @@ func (s *ContentModerationService) callModeration(ctx context.Context, cfg *Cont
s.markAPIKeySuccess(key, latency, httpStatus)
return result, nil
}
s.markAPIKeyFailure(key, err.Error(), latency, httpStatus)
s.markAPIKeyError(key, err.Error(), latency, httpStatus)
lastErr = err
if httpStatus == http.StatusBadRequest {
break
}
if attempt == attempts-1 {
break
}
@@ -1599,7 +1619,7 @@ func (s *ContentModerationService) markAPIKeySuccess(key string, latencyMS int,
state.LastTested = true
}
func (s *ContentModerationService) markAPIKeyFailure(key string, errText string, latencyMS int, httpStatus int) {
func (s *ContentModerationService) markAPIKeyError(key string, errText string, latencyMS int, httpStatus int) {
hash := moderationAPIKeyHash(key)
if hash == "" || s == nil {
return
@@ -1607,14 +1627,29 @@ func (s *ContentModerationService) markAPIKeyFailure(key string, errText string,
s.keyHealthMu.Lock()
defer s.keyHealthMu.Unlock()
state := s.ensureAPIKeyHealthLocked(hash, maskSecretTail(key))
state.FailureCount++
if contentModerationFreezeDurationForHTTPStatus(httpStatus) > 0 {
state.FailureCount++
}
state.LastError = trimRunes(errText, 180)
state.LastCheckedAt = time.Now()
state.LastLatencyMS = latencyMS
state.LastHTTPStatus = httpStatus
state.LastTested = true
if state.FailureCount >= contentModerationKeyFailureFreezeThreshold {
state.FrozenUntil = time.Now().Add(contentModerationKeyFreezeDuration)
if freezeDuration := contentModerationFreezeDurationForHTTPStatus(httpStatus); freezeDuration > 0 {
state.FrozenUntil = time.Now().Add(freezeDuration)
}
}
func contentModerationFreezeDurationForHTTPStatus(httpStatus int) time.Duration {
switch httpStatus {
case 0, http.StatusBadRequest:
return 0
case http.StatusUnauthorized, http.StatusForbidden:
return contentModerationKeyAuthFreezeDuration
case http.StatusTooManyRequests, 529:
return contentModerationKeyRateLimitFreezeDuration
default:
return contentModerationKeyHTTPErrorFreezeDuration
}
}
@@ -1929,6 +1964,37 @@ func normalizeModerationAPIKeys(keys []string) []string {
return out
}
func deleteModerationAPIKeysByHash(keys []string, hashes []string) []string {
keys = normalizeModerationAPIKeys(keys)
deleteHashes := make(map[string]struct{}, len(hashes))
for _, hash := range hashes {
hash = normalizeContentModerationHash(hash)
if hash != "" {
deleteHashes[hash] = struct{}{}
}
}
if len(deleteHashes) == 0 {
return keys
}
out := make([]string, 0, len(keys))
for _, key := range keys {
if _, ok := deleteHashes[moderationAPIKeyHash(key)]; ok {
continue
}
out = append(out, key)
}
return out
}
func normalizeContentModerationAPIKeysMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case contentModerationAPIKeysModeReplace:
return contentModerationAPIKeysModeReplace
default:
return contentModerationAPIKeysModeAppend
}
}
func normalizeContentModerationHash(inputHash string) string {
inputHash = strings.ToLower(strings.TrimSpace(inputHash))
if len(inputHash) != sha256.Size*2 {

View File

@@ -1,7 +1,9 @@
package service
import (
"crypto/rand"
"fmt"
"math/big"
"strings"
"github.com/tidwall/gjson"
@@ -291,6 +293,17 @@ func normalizeModerationImages(images []string) []string {
return out
}
func limitContentModerationImages(images []string) []string {
if len(images) <= maxContentModerationInputImages {
return images
}
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(images))))
if err != nil {
return images[:maxContentModerationInputImages]
}
return []string{images[int(idx.Int64())]}
}
func addModerationText(parts *[]string, text string) {
text = strings.TrimSpace(text)
if text == "" {

View File

@@ -6,6 +6,7 @@ import (
)
var contentModerationSecretPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bhttps?://[^\s"'<>,。;、]+`),
regexp.MustCompile(`(?i)\b((?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|token|session|cookie|set[_-]?cookie|authorization|bearer|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key)\s*[:=]\s*)(["']?)[^"'\s,;,。;、]{6,}`),
regexp.MustCompile(`(?i)\b(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}`),
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`),
@@ -24,9 +25,9 @@ func redactContentModerationSecrets(text string) string {
out := text
for idx, pattern := range contentModerationSecretPatterns {
switch idx {
case 0:
out = pattern.ReplaceAllString(out, `${1}${2}[已脱敏]`)
case 1:
out = pattern.ReplaceAllString(out, `${1}${2}[已脱敏]`)
case 2:
out = pattern.ReplaceAllString(out, `${1}[已脱敏]`)
default:
out = pattern.ReplaceAllString(out, `[已脱敏]`)

View File

@@ -301,13 +301,14 @@ func TestBuildContentModerationLog_RedactsInputExcerpt(t *testing.T) {
}
func TestRedactContentModerationSecrets_LongHexAndTokens(t *testing.T) {
input := "你哈市多大事cf5bbdc4cd508f3aaf0d2070d529d4a4ac29099f8ecc357f696df28e1df91554 token=abc123456789xyz Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signaturepart"
input := "你哈市多大事cf5bbdc4cd508f3aaf0d2070d529d4a4ac29099f8ecc357f696df28e1df91554 token=abc123456789xyz Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signaturepart https://example.com/private/path?token=abc123"
out := redactContentModerationSecrets(input)
require.NotContains(t, out, "cf5bbdc4cd508f3aaf0d2070d529d4a4ac29099f8ecc357f696df28e1df91554")
require.NotContains(t, out, "abc123456789xyz")
require.NotContains(t, out, "eyJhbGciOiJIUzI1NiJ9")
require.NotContains(t, out, "https://example.com/private/path")
require.Contains(t, out, "[已脱敏]")
}
@@ -320,6 +321,61 @@ func TestContentModerationConfigNormalize_NonHitRetentionMaxThreeDays(t *testing
require.Equal(t, 3, cfg.NonHitRetentionDays)
}
func TestContentModerationUpdateConfig_AppendsAndDeletesAPIKeys(t *testing.T) {
cfg := defaultContentModerationConfig()
cfg.APIKeys = []string{"sk-old-a", "sk-old-b"}
rawCfg, err := json.Marshal(cfg)
require.NoError(t, err)
repo := &contentModerationTestSettingRepo{values: map[string]string{
SettingKeyContentModerationConfig: string(rawCfg),
}}
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
deleteHashes := []string{moderationAPIKeyHash("sk-old-a")}
addKeys := []string{"sk-new-c", "sk-old-b"}
view, err := svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{
APIKeys: &addKeys,
DeleteAPIKeyHashes: &deleteHashes,
})
require.NoError(t, err)
require.Equal(t, 2, view.APIKeyCount)
require.Equal(t, []string{maskSecretTail("sk-old-b"), maskSecretTail("sk-new-c")}, view.APIKeyMasks)
var saved ContentModerationConfig
require.NoError(t, json.Unmarshal([]byte(repo.values[SettingKeyContentModerationConfig]), &saved))
require.Equal(t, []string{"sk-old-b", "sk-new-c"}, saved.apiKeys())
}
func TestContentModerationUpdateConfig_ReplacesAPIKeysWhenRequested(t *testing.T) {
cfg := defaultContentModerationConfig()
cfg.APIKeys = []string{"sk-old-a", "sk-old-b"}
rawCfg, err := json.Marshal(cfg)
require.NoError(t, err)
repo := &contentModerationTestSettingRepo{values: map[string]string{
SettingKeyContentModerationConfig: string(rawCfg),
}}
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
deleteHashes := []string{moderationAPIKeyHash("sk-old-a")}
replaceKeys := []string{"sk-new-only"}
view, err := svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{
APIKeys: &replaceKeys,
APIKeysMode: contentModerationAPIKeysModeReplace,
DeleteAPIKeyHashes: &deleteHashes,
})
require.NoError(t, err)
require.Equal(t, 1, view.APIKeyCount)
require.Equal(t, []string{maskSecretTail("sk-new-only")}, view.APIKeyMasks)
var saved ContentModerationConfig
require.NoError(t, json.Unmarshal([]byte(repo.values[SettingKeyContentModerationConfig]), &saved))
require.Equal(t, []string{"sk-new-only"}, saved.apiKeys())
}
func TestExtractContentModerationInput_AnthropicImageSourceOnlyParticipatesInMemory(t *testing.T) {
body := []byte(`{
"messages": [
@@ -395,6 +451,32 @@ func TestExtractContentModerationInput_OpenAIImagesIncludesPromptAndImages(t *te
require.Equal(t, []string{"https://example.com/source.png", "data:image/png;base64,aGVsbG8="}, input.Images)
}
func TestContentModerationInput_NormalizeRandomSamplesOneImageForModerationAPI(t *testing.T) {
images := []string{
"data:image/png;base64,Zmlyc3Q=",
"data:image/png;base64,c2Vjb25k",
}
input := ContentModerationInput{
Text: "check image",
Images: append([]string(nil), images...),
}
input.Normalize()
require.Len(t, input.Images, 1)
require.Contains(t, images, input.Images[0])
require.Len(t, input.ModerationInput(), 2)
}
func TestBuildModerationTestInputRejectsMultipleImages(t *testing.T) {
_, _, err := buildModerationTestInput("check image", []string{
"data:image/png;base64,Zmlyc3Q=",
"data:image/png;base64,c2Vjb25k",
})
require.Error(t, err)
require.Contains(t, err.Error(), "最多上传 1 张测试图片")
}
func TestExtractContentModerationInput_OpenAIResponsesCodexPayloadUsesLastUserMessage(t *testing.T) {
body := []byte(`{
"model":"gpt-5.5",
@@ -562,6 +644,105 @@ func TestBuildContentModerationTestAuditResult_UsesConfiguredThresholdsOnly(t *t
require.Equal(t, 0.98, result.Thresholds["harassment"])
}
func TestContentModerationCallModeration_400DoesNotFreezeAPIKey(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"message":"Number of images (5) exceeds maximum of 1","type":"invalid_request_error","param":"input","code":"too_many_images"}}`))
}))
defer server.Close()
cfg := defaultContentModerationConfig()
cfg.BaseURL = server.URL
cfg.APIKeys = []string{"sk-test"}
cfg.RetryCount = 5
svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil)
_, err := svc.callModeration(context.Background(), cfg, "hello")
require.Error(t, err)
require.Equal(t, 1, requestCount)
status := svc.apiKeyStatusForHash(0, moderationAPIKeyHash("sk-test"), maskSecretTail("sk-test"), true)
require.Equal(t, "error", status.Status)
require.Equal(t, http.StatusBadRequest, status.LastHTTPStatus)
require.Zero(t, status.FailureCount)
require.Nil(t, status.FrozenUntil)
}
func TestContentModerationCallModeration_FreezesByHTTPStatus(t *testing.T) {
tests := []struct {
name string
statusCode int
minFreeze time.Duration
maxFreeze time.Duration
}{
{name: "401 freezes ten minutes", statusCode: http.StatusUnauthorized, minFreeze: 9*time.Minute + 55*time.Second, maxFreeze: 10*time.Minute + time.Second},
{name: "403 freezes ten minutes", statusCode: http.StatusForbidden, minFreeze: 9*time.Minute + 55*time.Second, maxFreeze: 10*time.Minute + time.Second},
{name: "429 freezes one minute", statusCode: http.StatusTooManyRequests, minFreeze: 55 * time.Second, maxFreeze: time.Minute + time.Second},
{name: "529 freezes one minute", statusCode: 529, minFreeze: 55 * time.Second, maxFreeze: time.Minute + time.Second},
{name: "500 freezes ten seconds", statusCode: http.StatusInternalServerError, minFreeze: 5 * time.Second, maxFreeze: 11 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
_, _ = w.Write([]byte(`{"error":{"message":"upstream error"}}`))
}))
defer server.Close()
cfg := defaultContentModerationConfig()
cfg.BaseURL = server.URL
cfg.APIKeys = []string{"sk-test"}
cfg.RetryCount = 0
svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil)
_, err := svc.callModeration(context.Background(), cfg, "hello")
require.Error(t, err)
status := svc.apiKeyStatusForHash(0, moderationAPIKeyHash("sk-test"), maskSecretTail("sk-test"), true)
require.Equal(t, "frozen", status.Status)
require.Equal(t, tt.statusCode, status.LastHTTPStatus)
require.Equal(t, 1, status.FailureCount)
require.NotNil(t, status.FrozenUntil)
remaining := time.Until(*status.FrozenUntil)
require.GreaterOrEqual(t, remaining, tt.minFreeze)
require.LessOrEqual(t, remaining, tt.maxFreeze)
})
}
}
func TestContentModerationTestAPIKeys_400DoesNotFreezeAPIKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"message":"invalid moderation request"}}`))
}))
defer server.Close()
svc := NewContentModerationService(
&contentModerationTestSettingRepo{values: map[string]string{}},
nil,
nil,
nil,
nil,
nil,
nil,
)
result, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{
APIKeys: []string{"sk-test"},
BaseURL: server.URL,
Prompt: "hello",
})
require.NoError(t, err)
require.Len(t, result.Items, 1)
require.Equal(t, "error", result.Items[0].Status)
require.Equal(t, http.StatusBadRequest, result.Items[0].LastHTTPStatus)
require.Zero(t, result.Items[0].FailureCount)
require.Nil(t, result.Items[0].FrozenUntil)
}
func TestContentModerationCheck_PreHashUsesRedisHashCache(t *testing.T) {
cfg := defaultContentModerationConfig()
cfg.Enabled = true