diff --git a/backend/internal/handler/admin/content_moderation_handler.go b/backend/internal/handler/admin/content_moderation_handler.go index 88b93527..4266f5d8 100644 --- a/backend/internal/handler/admin/content_moderation_handler.go +++ b/backend/internal/handler/admin/content_moderation_handler.go @@ -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, diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go index 192946ce..7cda8293 100644 --- a/backend/internal/service/content_moderation.go +++ b/backend/internal/service/content_moderation.go @@ -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 { diff --git a/backend/internal/service/content_moderation_input.go b/backend/internal/service/content_moderation_input.go index a0b3b663..67df397d 100644 --- a/backend/internal/service/content_moderation_input.go +++ b/backend/internal/service/content_moderation_input.go @@ -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 == "" { diff --git a/backend/internal/service/content_moderation_redact.go b/backend/internal/service/content_moderation_redact.go index 548cbeab..473c8178 100644 --- a/backend/internal/service/content_moderation_redact.go +++ b/backend/internal/service/content_moderation_redact.go @@ -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, `[已脱敏]`) diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go index cc888f28..23dfcdf1 100644 --- a/backend/internal/service/content_moderation_test.go +++ b/backend/internal/service/content_moderation_test.go @@ -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 diff --git a/frontend/src/api/admin/riskControl.ts b/frontend/src/api/admin/riskControl.ts index 5f42b01d..e63a53a2 100644 --- a/frontend/src/api/admin/riskControl.ts +++ b/frontend/src/api/admin/riskControl.ts @@ -80,6 +80,8 @@ export interface UpdateContentModerationConfig { model?: string api_key?: string api_keys?: string[] + api_keys_mode?: 'append' | 'replace' + delete_api_key_hashes?: string[] clear_api_key?: boolean timeout_ms?: number sample_rate?: number diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 743f9415..d4a3251f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2348,13 +2348,25 @@ export default { apiKeys: 'OpenAI API Keys', apiKeyCount: '{count} keys', apiKeyPlaceholder: 'Enter API Key', - apiKeysPlaceholder: 'One API Key per line', - apiKeysPlaceholderKeep: 'Leave empty to keep stored keys; enter values to replace them', - apiKeysHint: '{count} keys are currently stored. Values entered here replace stored keys; leave empty to keep them.', + apiKeysPlaceholder: 'Add API Keys, one per line. They will be appended on save.', + apiKeysPlaceholderReplace: 'Replace API Keys, one per line. Stored keys will be replaced on save.', + apiKeysPlaceholderKeep: 'Add API Keys, one per line. They will be appended on save.', + apiKeysHint: '{count} keys are currently stored. This input only adds keys; save appends and de-duplicates them.', + apiKeysWriteMode: 'Write mode', + apiKeysModeAppend: 'Add', + apiKeysModeReplace: 'Replace', + apiKeysModeAppendHint: 'Default: save appends input keys and keeps stored keys.', + apiKeysModeReplaceHint: 'Replace mode: save replaces all stored keys with input keys.', + apiKeysReplaceWarning: 'Replace mode', + apiKeysReplaceNoInput: 'Replace mode requires at least 1 API Key', apiKeyPlaceholderKeep: 'Leave empty to keep current key', apiKeyWillClear: 'Configured key will be cleared on save', apiKeyConfigured: 'Configured', apiKeyTemporary: 'Pending', + apiKeyPendingDelete: 'Pending delete', + apiKeyPendingDeleteCount: '{count} keys pending deletion', + deleteApiKey: 'Delete this key', + undoDeleteApiKey: 'Undo delete', inputApiKeyCount: '{count} keys in input', storedApiKeyCount: '{count} stored keys', testInputApiKeys: 'Test input keys', @@ -2365,8 +2377,12 @@ export default { apiKeyTestDone: 'Key test completed for {count} keys', apiKeyTestFailed: 'Failed to test OpenAI API Keys', apiKeyHealth: 'Key Availability', - apiKeyFreezeRule: 'Three consecutive failures freeze a key for 1 minute; moderation rotation skips frozen keys.', + apiKeyFreezeRule: '400 does not freeze; 401/403 freeze for 10 minutes; 429/529 freeze for 1 minute; other HTTP errors freeze for 10 seconds.', apiKeyRows: '{count} keys', + apiKeyRowsCollapsed: '{count} keys hidden', + apiKeyRowsExpanded: 'Showing all {count} keys', + expandApiKeyRows: 'Expand', + collapseApiKeyRows: 'Collapse', apiKeyHealthEmpty: 'No key status yet', apiKeyHealthEmptyHint: 'Save keys or test input keys to see availability.', apiKeyStatusOk: 'Available', @@ -2383,7 +2399,7 @@ export default { auditTestInputHint: 'Enter a prompt and upload or paste images; images are sent as base64 and are not stored.', auditTestPromptPlaceholder: 'Enter a user prompt to test; leave empty to only test key availability.', auditTestImages: 'Test Images', - auditTestImagesHint: 'Upload, drag, or paste images. Up to 4 images, 8MB each.', + auditTestImagesHint: 'Upload, drag, or paste images. Up to 1 image, 8MB each.', addAuditTestImage: 'Add image', clearAuditTest: 'Clear test', auditTestImageLimit: 'You can add up to {count} test images', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 246f9832..8381cd86 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2425,13 +2425,25 @@ export default { apiKeys: 'OpenAI API Keys', apiKeyCount: '{count} 个 Key', apiKeyPlaceholder: '请输入 API Key', - apiKeysPlaceholder: '每行一个 API Key', - apiKeysPlaceholderKeep: '留空保持已保存的 Key;填写后将替换为这些 Key', - apiKeysHint: '当前已保存 {count} 个 Key;填写文本框会替换已保存 Key,留空则保持不变。', + apiKeysPlaceholder: '新增 API Key,每行一个;保存后会追加到已保存 Key', + apiKeysPlaceholderReplace: '覆盖保存 API Key,每行一个;保存后会替换全部已保存 Key', + apiKeysPlaceholderKeep: '新增 API Key,每行一个;保存后会追加到已保存 Key', + apiKeysHint: '当前已保存 {count} 个 Key;输入区只用于新增,保存时会增量追加并自动去重。', + apiKeysWriteMode: '写入方式', + apiKeysModeAppend: '增量添加', + apiKeysModeReplace: '覆盖保存', + apiKeysModeAppendHint: '默认模式:保存时追加输入区 Key,并保留已保存 Key。', + apiKeysModeReplaceHint: '覆盖模式:保存时用输入区 Key 替换全部已保存 Key。', + apiKeysReplaceWarning: '覆盖模式', + apiKeysReplaceNoInput: '覆盖保存至少需要输入 1 个 API Key', apiKeyPlaceholderKeep: '留空保持不变', apiKeyWillClear: '保存后清除已配置 Key', apiKeyConfigured: '已配置', apiKeyTemporary: '待保存', + apiKeyPendingDelete: '待删除', + apiKeyPendingDeleteCount: '待删除 {count} 个 Key', + deleteApiKey: '删除这个 Key', + undoDeleteApiKey: '撤销删除', inputApiKeyCount: '输入区 {count} 个 Key', storedApiKeyCount: '已保存 {count} 个 Key', testInputApiKeys: '测试输入区 Key', @@ -2442,8 +2454,12 @@ export default { apiKeyTestDone: 'Key 测试完成,共 {count} 个', apiKeyTestFailed: '测试 OpenAI API Key 失败', apiKeyHealth: 'Key 可用状态', - apiKeyFreezeRule: '连续 3 次失败会冻结 1 分钟,审计轮询会自动跳过。', - apiKeyRows: '{count} 个', + apiKeyFreezeRule: '400 不冻结;401/403 冻结 10 分钟;429/529 冻结 1 分钟;其他 HTTP 错误冻结 10 秒。', + apiKeyRows: '{count} 个 Key', + apiKeyRowsCollapsed: '已隐藏 {count} 个 Key', + apiKeyRowsExpanded: '正在显示全部 {count} 个 Key', + expandApiKeyRows: '展开', + collapseApiKeyRows: '收起', apiKeyHealthEmpty: '暂无 Key 状态', apiKeyHealthEmptyHint: '保存 Key 或测试输入区 Key 后会显示可用性。', apiKeyStatusOk: '可用', @@ -2460,7 +2476,7 @@ export default { auditTestInputHint: '可填写提示词并上传或粘贴图片;图片以 base64 发送,不会保存文件。', auditTestPromptPlaceholder: '输入要测试的用户提示词;留空时仅测试 Key 可用性。', auditTestImages: '测试图片', - auditTestImagesHint: '支持上传、拖拽或粘贴图片,最多 4 张,每张不超过 8MB。', + auditTestImagesHint: '支持上传、拖拽或粘贴图片,最多 1 张,每张不超过 8MB。', addAuditTestImage: '添加图片', clearAuditTest: '清空试跑', auditTestImageLimit: '最多只能添加 {count} 张测试图片', diff --git a/frontend/src/views/admin/RiskControlView.vue b/frontend/src/views/admin/RiskControlView.vue index 0041cd8e..74db4772 100644 --- a/frontend/src/views/admin/RiskControlView.vue +++ b/frontend/src/views/admin/RiskControlView.vue @@ -331,12 +331,12 @@ + :disabled="apiKeyTesting || effectiveStoredApiKeyCount === 0 || pendingDeletedApiKeyCount > 0 || configForm.clear_api_key || configForm.api_keys_mode === 'replace'" + @click="testApiKeys(false)" + > + + {{ storedApiKeyTestButtonText }} + + + + @@ -368,6 +394,12 @@ {{ t('admin.riskControl.apiKeyWillClear') }} + + {{ t('admin.riskControl.apiKeyPendingDeleteCount', { count: pendingDeletedApiKeyCount }) }} + + + {{ t('admin.riskControl.apiKeysReplaceWarning') }} +
@@ -431,12 +463,12 @@
-
-
+
+

{{ t('admin.riskControl.apiKeyHealth') }}

{{ t('admin.riskControl.apiKeyFreezeRule') }}

- + {{ t('admin.riskControl.apiKeyRows', { count: apiKeyRows.length }) }}
@@ -446,33 +478,61 @@

{{ t('admin.riskControl.apiKeyHealthEmpty') }}

{{ t('admin.riskControl.apiKeyHealthEmptyHint') }}

-
-
-
-
-
- {{ row.masked || '-' }} - - {{ row.configured ? t('admin.riskControl.apiKeyConfigured') : t('admin.riskControl.apiKeyTemporary') }} - +
+
+
+
+
+
+ {{ row.masked || '-' }} + + {{ isStoredApiKeyPendingDelete(row) ? t('admin.riskControl.apiKeyPendingDelete') : row.configured ? t('admin.riskControl.apiKeyConfigured') : t('admin.riskControl.apiKeyTemporary') }} + +
+

{{ apiKeyStatusMeta(row) }}

+
+
+ + + {{ apiKeyStatusLabel(row.status) }} + +
-

{{ apiKeyStatusMeta(row) }}

- - - {{ apiKeyStatusLabel(row.status) }} - +

+ {{ row.last_error }} +

-

- {{ row.last_error }} -

+
+ +
+ + {{ apiKeyRowsExpanded ? t('admin.riskControl.apiKeyRowsExpanded', { count: apiKeyRows.length }) : t('admin.riskControl.apiKeyRowsCollapsed', { count: hiddenApiKeyRowCount }) }} + +
@@ -780,6 +840,7 @@ import { formatDateTime as formatDateTimeValue } from '@/utils/format' type SettingsTab = 'basic' | 'scope' | 'runtime' | 'response' | 'retention' type WorkerSlotState = 'active' | 'idle' | 'disabled' +type APIKeysWriteMode = 'append' | 'replace' type OverviewIcon = 'shield' | 'key' | 'users' | 'document' type OverviewItem = { key: string @@ -798,8 +859,9 @@ type ModerationScoreRow = { hit: boolean } -const maxModerationTestImages = 4 +const maxModerationTestImages = 1 const maxModerationTestImageSize = 8 * 1024 * 1024 +const maxVisibleApiKeyRows: number = 3 const { t } = useI18n() const appStore = useAppStore() @@ -819,6 +881,8 @@ const groups = ref([]) const logs = ref([]) const status = ref(null) const testedApiKeyStatuses = ref([]) +const pendingDeleteApiKeyHashes = ref([]) +const apiKeyRowsExpanded = ref(false) const moderationTestPrompt = ref('') const moderationTestImages = ref([]) const moderationTestResult = ref(null) @@ -836,6 +900,7 @@ const configForm = reactive({ api_key_count: 0, api_key_masks: [] as string[], api_key_statuses: [] as ContentModerationAPIKeyStatus[], + api_keys_mode: 'append' as APIKeysWriteMode, clear_api_key: false, timeout_ms: 3000, retry_count: 2, @@ -922,14 +987,24 @@ const filteredGroups = computed(() => { }) }) -const apiKeyPlaceholder = computed(() => { - if (configForm.clear_api_key) return t('admin.riskControl.apiKeyWillClear') - if (configForm.api_key_configured) return t('admin.riskControl.apiKeysPlaceholderKeep') - return t('admin.riskControl.apiKeysPlaceholder') -}) - const inputApiKeyCount = computed(() => parseApiKeys(configForm.api_keys_text).length) +const pendingDeletedApiKeyCount = computed(() => pendingDeleteApiKeyHashes.value.length) + +const effectiveStoredApiKeyCount = computed(() => Math.max(0, configForm.api_key_count - pendingDeletedApiKeyCount.value)) + +const apiKeysPlaceholder = computed(() => ( + configForm.api_keys_mode === 'replace' + ? t('admin.riskControl.apiKeysPlaceholderReplace') + : t('admin.riskControl.apiKeysPlaceholder') +)) + +const apiKeysModeHint = computed(() => ( + configForm.api_keys_mode === 'replace' + ? t('admin.riskControl.apiKeysModeReplaceHint') + : t('admin.riskControl.apiKeysModeAppendHint') +)) + const hasModerationAuditInput = computed(() => { return moderationTestPrompt.value.trim() !== '' || moderationTestImages.value.length > 0 }) @@ -954,6 +1029,19 @@ const apiKeyRows = computed(() => [ ...testedApiKeyStatuses.value, ]) +const visibleApiKeyRows = computed(() => { + if (apiKeyRowsExpanded.value) return apiKeyRows.value + return apiKeyRows.value.slice(0, maxVisibleApiKeyRows) +}) + +const hiddenApiKeyRowCount = computed(() => Math.max(0, apiKeyRows.value.length - visibleApiKeyRows.value.length)) + +const canToggleApiKeyRows = computed(() => apiKeyRows.value.length > maxVisibleApiKeyRows) + +const activeSavedApiKeyRows = computed(() => ( + savedApiKeyRows.value.filter((row) => !isStoredApiKeyPendingDelete(row)) +)) + const apiKeyHealthBadges = computed>(() => { const counts: Record = { ok: 0, @@ -961,11 +1049,11 @@ const apiKeyHealthBadges = computed 0) { - counts.unknown = configForm.api_key_count + if (activeSavedApiKeyRows.value.length === 0 && effectiveStoredApiKeyCount.value > 0) { + counts.unknown = effectiveStoredApiKeyCount.value } return (['ok', 'frozen', 'error', 'unknown'] as Array) .map((item) => ({ status: item, count: counts[item] })) @@ -1085,8 +1173,11 @@ function applyConfig(config: ContentModerationConfig) { configForm.api_key_count = config.api_key_count || 0 configForm.api_key_masks = Array.isArray(config.api_key_masks) ? [...config.api_key_masks] : [] configForm.api_key_statuses = Array.isArray(config.api_key_statuses) ? [...config.api_key_statuses] : [] + configForm.api_keys_mode = 'append' configForm.clear_api_key = false + pendingDeleteApiKeyHashes.value = [] testedApiKeyStatuses.value = [] + apiKeyRowsExpanded.value = false configForm.timeout_ms = config.timeout_ms || 3000 configForm.retry_count = config.retry_count ?? 2 configForm.sample_rate = config.sample_rate ?? 100 @@ -1119,6 +1210,7 @@ async function loadAll() { status.value = runtimeStatus if (Array.isArray(runtimeStatus.api_key_statuses)) { configForm.api_key_statuses = [...runtimeStatus.api_key_statuses] + prunePendingDeleteAPIKeyHashes() } await loadLogs() } catch (err: unknown) { @@ -1135,6 +1227,7 @@ async function loadStatus(silent = true) { status.value = runtimeStatus if (Array.isArray(runtimeStatus.api_key_statuses)) { configForm.api_key_statuses = [...runtimeStatus.api_key_statuses] + prunePendingDeleteAPIKeyHashes() } } catch (err: unknown) { if (!silent) { @@ -1173,10 +1266,18 @@ async function saveConfig() { pre_hash_check_enabled: configForm.pre_hash_check_enabled, } const keys = parseApiKeys(configForm.api_keys_text) + if (!payload.clear_api_key && configForm.api_keys_mode === 'replace' && keys.length === 0) { + appStore.showError(t('admin.riskControl.apiKeysReplaceNoInput')) + return + } if (keys.length > 0) { payload.api_keys = keys + payload.api_keys_mode = configForm.api_keys_mode payload.clear_api_key = false } + if (!payload.clear_api_key && configForm.api_keys_mode !== 'replace' && pendingDeleteApiKeyHashes.value.length > 0) { + payload.delete_api_key_hashes = [...pendingDeleteApiKeyHashes.value] + } const updated = await adminAPI.riskControl.updateConfig(payload) applyConfig(updated) @@ -1305,7 +1406,16 @@ function toggleClearApiKey() { configForm.clear_api_key = !configForm.clear_api_key if (configForm.clear_api_key) { configForm.api_keys_text = '' + configForm.api_keys_mode = 'append' testedApiKeyStatuses.value = [] + pendingDeleteApiKeyHashes.value = [] + } +} + +function setAPIKeysMode(mode: APIKeysWriteMode) { + configForm.api_keys_mode = mode + if (mode === 'replace') { + pendingDeleteApiKeyHashes.value = [] } } @@ -1350,6 +1460,25 @@ function mergeConfiguredAPIKeyStatuses(items: ContentModerationAPIKeyStatus[]) { configForm.api_key_statuses = configForm.api_key_statuses.map((item) => updates.get(item.key_hash) ?? item) } +function toggleDeleteStoredApiKey(row: ContentModerationAPIKeyStatus) { + if (!row.configured || !row.key_hash) return + const index = pendingDeleteApiKeyHashes.value.indexOf(row.key_hash) + if (index >= 0) { + pendingDeleteApiKeyHashes.value.splice(index, 1) + return + } + pendingDeleteApiKeyHashes.value.push(row.key_hash) +} + +function isStoredApiKeyPendingDelete(row: ContentModerationAPIKeyStatus): boolean { + return row.configured && row.key_hash !== '' && pendingDeleteApiKeyHashes.value.includes(row.key_hash) +} + +function prunePendingDeleteAPIKeyHashes() { + const currentHashes = new Set(savedApiKeyRows.value.map((row) => row.key_hash).filter(Boolean)) + pendingDeleteApiKeyHashes.value = pendingDeleteApiKeyHashes.value.filter((hash) => currentHashes.has(hash)) +} + function clearModerationTestInput() { moderationTestPrompt.value = '' moderationTestImages.value = []