fix moderation key handling and key UI
This commit is contained in:
parent
f3577bc69c
commit
0eca600ffa
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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, `[已脱敏]`)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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} 张测试图片',
|
||||
|
||||
@ -331,12 +331,12 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary inline-flex items-center gap-2"
|
||||
:disabled="apiKeyTesting || !configForm.api_key_configured || configForm.clear_api_key"
|
||||
@click="testApiKeys(false)"
|
||||
>
|
||||
<Icon name="shield" size="sm" />
|
||||
{{ storedApiKeyTestButtonText }}
|
||||
</button>
|
||||
:disabled="apiKeyTesting || effectiveStoredApiKeyCount === 0 || pendingDeletedApiKeyCount > 0 || configForm.clear_api_key || configForm.api_keys_mode === 'replace'"
|
||||
@click="testApiKeys(false)"
|
||||
>
|
||||
<Icon name="shield" size="sm" />
|
||||
{{ storedApiKeyTestButtonText }}
|
||||
</button>
|
||||
<button
|
||||
v-if="configForm.api_key_configured"
|
||||
type="button"
|
||||
@ -351,10 +351,36 @@
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 p-4 xl:grid-cols-[minmax(0,1fr)_minmax(360px,440px)]">
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-col gap-2 rounded-lg border border-gray-100 bg-gray-50 p-2 dark:border-dark-700 dark:bg-dark-900/30 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">{{ t('admin.riskControl.apiKeysWriteMode') }}</span>
|
||||
<span class="ml-2">{{ apiKeysModeHint }}</span>
|
||||
</div>
|
||||
<div class="inline-flex rounded-lg bg-white p-1 shadow-sm dark:bg-dark-800">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
:class="configForm.api_keys_mode === 'append' ? 'bg-primary-500 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700'"
|
||||
:disabled="configForm.clear_api_key"
|
||||
@click="setAPIKeysMode('append')"
|
||||
>
|
||||
{{ t('admin.riskControl.apiKeysModeAppend') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
:class="configForm.api_keys_mode === 'replace' ? 'bg-amber-500 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700'"
|
||||
:disabled="configForm.clear_api_key"
|
||||
@click="setAPIKeysMode('replace')"
|
||||
>
|
||||
{{ t('admin.riskControl.apiKeysModeReplace') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="configForm.api_keys_text"
|
||||
class="input min-h-44 resize-y font-mono text-sm"
|
||||
:placeholder="apiKeyPlaceholder"
|
||||
:placeholder="apiKeysPlaceholder"
|
||||
autocomplete="new-password"
|
||||
:disabled="configForm.clear_api_key"
|
||||
></textarea>
|
||||
@ -368,6 +394,12 @@
|
||||
<span v-if="configForm.clear_api_key" class="inline-flex rounded-md bg-red-50 px-2 py-1 text-red-700 dark:bg-red-900/20 dark:text-red-300">
|
||||
{{ t('admin.riskControl.apiKeyWillClear') }}
|
||||
</span>
|
||||
<span v-else-if="pendingDeletedApiKeyCount > 0" class="inline-flex rounded-md bg-amber-50 px-2 py-1 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
||||
{{ t('admin.riskControl.apiKeyPendingDeleteCount', { count: pendingDeletedApiKeyCount }) }}
|
||||
</span>
|
||||
<span v-if="configForm.api_keys_mode === 'replace'" class="inline-flex rounded-md bg-amber-50 px-2 py-1 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
||||
{{ t('admin.riskControl.apiKeysReplaceWarning') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900/30" @paste="handleModerationImagePaste">
|
||||
@ -431,12 +463,12 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900/30">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.apiKeyHealth') }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.apiKeyFreezeRule') }}</p>
|
||||
</div>
|
||||
<span class="inline-flex rounded-md bg-white px-2 py-1 text-xs font-medium text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300">
|
||||
<span class="inline-flex shrink-0 items-center whitespace-nowrap rounded-full bg-white px-2 py-0.5 text-[11px] font-medium leading-5 text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300">
|
||||
{{ t('admin.riskControl.apiKeyRows', { count: apiKeyRows.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
@ -446,33 +478,61 @@
|
||||
<p class="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">{{ t('admin.riskControl.apiKeyHealthEmpty') }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.apiKeyHealthEmptyHint') }}</p>
|
||||
</div>
|
||||
<div v-else class="max-h-72 space-y-2 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="(row, index) in apiKeyRows"
|
||||
:key="apiKeyRowKey(row, index)"
|
||||
class="rounded-lg border border-gray-100 bg-white p-3 shadow-sm dark:border-dark-700 dark:bg-dark-800"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="truncate font-mono text-sm font-semibold text-gray-900 dark:text-white">{{ row.masked || '-' }}</span>
|
||||
<span
|
||||
class="inline-flex rounded-md px-1.5 py-0.5 text-[11px] font-medium"
|
||||
:class="row.configured ? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'"
|
||||
>
|
||||
{{ row.configured ? t('admin.riskControl.apiKeyConfigured') : t('admin.riskControl.apiKeyTemporary') }}
|
||||
</span>
|
||||
<div v-else class="space-y-2">
|
||||
<div class="space-y-2" :class="apiKeyRowsExpanded ? 'max-h-72 overflow-y-auto pr-1' : ''">
|
||||
<div
|
||||
v-for="(row, index) in visibleApiKeyRows"
|
||||
:key="apiKeyRowKey(row, index)"
|
||||
class="rounded-lg border bg-white p-2.5 shadow-sm dark:bg-dark-800"
|
||||
:class="isStoredApiKeyPendingDelete(row) ? 'border-amber-200 opacity-70 dark:border-amber-800/60' : 'border-gray-100 dark:border-dark-700'"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="truncate font-mono text-sm font-semibold text-gray-900 dark:text-white">{{ row.masked || '-' }}</span>
|
||||
<span
|
||||
class="inline-flex rounded-md px-1.5 py-0.5 text-[11px] font-medium"
|
||||
:class="row.configured ? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'"
|
||||
>
|
||||
{{ isStoredApiKeyPendingDelete(row) ? t('admin.riskControl.apiKeyPendingDelete') : row.configured ? t('admin.riskControl.apiKeyConfigured') : t('admin.riskControl.apiKeyTemporary') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ apiKeyStatusMeta(row) }}</p>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 items-center gap-1.5">
|
||||
<span class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium" :class="apiKeyStatusBadgeClass(row.status)">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="apiKeyStatusDotClass(row.status)"></span>
|
||||
{{ apiKeyStatusLabel(row.status) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="row.configured && !configForm.clear_api_key"
|
||||
type="button"
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dark-700 dark:hover:text-gray-200"
|
||||
:title="isStoredApiKeyPendingDelete(row) ? t('admin.riskControl.undoDeleteApiKey') : t('admin.riskControl.deleteApiKey')"
|
||||
@click="toggleDeleteStoredApiKey(row)"
|
||||
>
|
||||
<Icon :name="isStoredApiKeyPendingDelete(row) ? 'refresh' : 'trash'" size="xs" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ apiKeyStatusMeta(row) }}</p>
|
||||
</div>
|
||||
<span class="inline-flex flex-shrink-0 items-center gap-1.5 rounded-full px-2 py-1 text-xs font-medium" :class="apiKeyStatusBadgeClass(row.status)">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="apiKeyStatusDotClass(row.status)"></span>
|
||||
{{ apiKeyStatusLabel(row.status) }}
|
||||
</span>
|
||||
<p v-if="row.last_error" class="mt-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-xs leading-5 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
||||
{{ row.last_error }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="row.last_error" class="mt-2 rounded-md bg-amber-50 px-2 py-1.5 text-xs leading-5 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
||||
{{ row.last_error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="canToggleApiKeyRows" class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-200 bg-white px-3 py-2 text-xs text-gray-500 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400">
|
||||
<span class="min-w-0 truncate">
|
||||
{{ apiKeyRowsExpanded ? t('admin.riskControl.apiKeyRowsExpanded', { count: apiKeyRows.length }) : t('admin.riskControl.apiKeyRowsCollapsed', { count: hiddenApiKeyRowCount }) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 font-medium text-primary-600 transition-colors hover:bg-primary-50 hover:text-primary-700 dark:text-primary-300 dark:hover:bg-primary-900/20"
|
||||
@click="apiKeyRowsExpanded = !apiKeyRowsExpanded"
|
||||
>
|
||||
<Icon :name="apiKeyRowsExpanded ? 'chevronUp' : 'chevronDown'" size="xs" />
|
||||
{{ apiKeyRowsExpanded ? t('admin.riskControl.collapseApiKeyRows') : t('admin.riskControl.expandApiKeyRows') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<AdminGroup[]>([])
|
||||
const logs = ref<ContentModerationLog[]>([])
|
||||
const status = ref<ContentModerationRuntimeStatus | null>(null)
|
||||
const testedApiKeyStatuses = ref<ContentModerationAPIKeyStatus[]>([])
|
||||
const pendingDeleteApiKeyHashes = ref<string[]>([])
|
||||
const apiKeyRowsExpanded = ref<boolean>(false)
|
||||
const moderationTestPrompt = ref('')
|
||||
const moderationTestImages = ref<string[]>([])
|
||||
const moderationTestResult = ref<ContentModerationTestAuditResult | null>(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<ContentModerationAPIKeyStatus[]>(() => [
|
||||
...testedApiKeyStatuses.value,
|
||||
])
|
||||
|
||||
const visibleApiKeyRows = computed<ContentModerationAPIKeyStatus[]>(() => {
|
||||
if (apiKeyRowsExpanded.value) return apiKeyRows.value
|
||||
return apiKeyRows.value.slice(0, maxVisibleApiKeyRows)
|
||||
})
|
||||
|
||||
const hiddenApiKeyRowCount = computed<number>(() => Math.max(0, apiKeyRows.value.length - visibleApiKeyRows.value.length))
|
||||
|
||||
const canToggleApiKeyRows = computed<boolean>(() => apiKeyRows.value.length > maxVisibleApiKeyRows)
|
||||
|
||||
const activeSavedApiKeyRows = computed<ContentModerationAPIKeyStatus[]>(() => (
|
||||
savedApiKeyRows.value.filter((row) => !isStoredApiKeyPendingDelete(row))
|
||||
))
|
||||
|
||||
const apiKeyHealthBadges = computed<Array<{ status: ContentModerationAPIKeyStatus['status']; count: number }>>(() => {
|
||||
const counts: Record<ContentModerationAPIKeyStatus['status'], number> = {
|
||||
ok: 0,
|
||||
@ -961,11 +1049,11 @@ const apiKeyHealthBadges = computed<Array<{ status: ContentModerationAPIKeyStatu
|
||||
frozen: 0,
|
||||
unknown: 0,
|
||||
}
|
||||
for (const row of savedApiKeyRows.value) {
|
||||
for (const row of activeSavedApiKeyRows.value) {
|
||||
counts[row.status] = (counts[row.status] ?? 0) + 1
|
||||
}
|
||||
if (savedApiKeyRows.value.length === 0 && configForm.api_key_count > 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<ContentModerationAPIKeyStatus['status']>)
|
||||
.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 = []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user