Files
sub2api/openspec/changes/add-openai-compatible-prompt-audit/source-freeze/aicodex-prompt-audit-tracked.patch
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

2772 lines
122 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/ai-gateway/cmd/aicodex/main.go b/ai-gateway/cmd/aicodex/main.go
index 59d11dcb90f59c9868ca836b2acf1a827d78eeb0..e57948b376df63083dd1c24dd3a778707a9793f3 100644
--- a/ai-gateway/cmd/aicodex/main.go
+++ b/ai-gateway/cmd/aicodex/main.go
@@ -85,7 +85,7 @@ var (
migrationInitLogDBFn = model.InitLogDB
migrationCloseDBFn = model.CloseDB
promptAuditRunnerFactory = func() *promptaudit.Runner {
- return promptaudit.NewRunner(nil, nil, promptaudit.NewOpenAICompatibleClient(service.GetHttpClient()), nil)
+ return promptaudit.NewRunner(nil, nil, promptaudit.NewOpenAICompatibleClient(nil), nil)
}
)
diff --git a/ai-gateway/internal/controller/prompt_audit.go b/ai-gateway/internal/controller/prompt_audit.go
index 25a71a76ee20d9f6a950074c6dc83995a67fe75d..dc34813b01f25864c5451ac3f947e1331b2e1672 100644
--- a/ai-gateway/internal/controller/prompt_audit.go
+++ b/ai-gateway/internal/controller/prompt_audit.go
@@ -1,6 +1,7 @@
package controller
import (
+ "errors"
"net/http"
"strconv"
"strings"
@@ -9,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
appent "github.com/mt21625457/aicodex/ent"
"github.com/mt21625457/aicodex/internal/common"
+ "github.com/mt21625457/aicodex/internal/constant"
"github.com/mt21625457/aicodex/internal/service/promptaudit"
)
@@ -48,12 +50,21 @@ type promptAuditEventFilterRequest struct {
var (
previewDeletePromptAuditEventsByFilter = promptaudit.PreviewDeleteEventsByFilter
deletePromptAuditEventsByFilter = promptaudit.DeleteEventsByFilter
+ promptAuditConfigServiceFactory = func() *promptaudit.ConfigService { return promptaudit.NewConfigService(nil) }
)
func GetPromptAuditConfig(c *gin.Context) {
- cfg, err := promptaudit.NewConfigService(nil).Public(c.Request.Context())
+ cfg, err := promptAuditConfigServiceFactory().Public(c.Request.Context())
if err != nil {
- common.ApiError(c, err)
+ promptaudit.LogWarnEvent(
+ "prompt_guard.config_reload_degraded",
+ promptAuditLogFields(c,
+ promptaudit.Field("status", "degraded"),
+ promptaudit.Field("error_code", "config_read_failed"),
+ promptaudit.Field("error_kind", "config_read_failed"),
+ )...,
+ )
+ common.ApiErrorMsg(c, "读取提示词审计配置失败")
return
}
common.ApiSuccess(c, cfg)
@@ -68,8 +79,17 @@ func UpdatePromptAuditConfig(c *gin.Context) {
common.ApiErrorMsg(c, "invalid request body")
return
}
- cfg, err := promptaudit.NewConfigService(nil).Save(c.Request.Context(), req)
+ req.UpdatedBy = common.GetContextKeyInt(c, constant.ContextKeyUserId)
+ cfg, err := promptAuditConfigServiceFactory().Save(c.Request.Context(), req)
if err != nil {
+ var validationErr *promptaudit.ConfigValidationError
+ if errors.As(err, &validationErr) {
+ recordAdminAuditFailed(c, "prompt_audit.config.update", "prompt_audit_config", "global", validationErr.Code, promptAuditAdminAuditDetail(map[string]any{
+ "enabled": req.Enabled, "blocking_enabled": req.BlockingEnabled,
+ }))
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": validationErr.Message, "code": validationErr.Code})
+ return
+ }
recordAdminAuditFailed(c, "prompt_audit.config.update", "prompt_audit_config", "global", "save_config_failed", promptAuditAdminAuditDetail(map[string]any{
"enabled": req.Enabled,
"endpoint_count": len(req.Endpoints),
@@ -77,7 +97,7 @@ func UpdatePromptAuditConfig(c *gin.Context) {
"audit_group_mode": req.AuditGroupMode,
"audit_group_count": len(req.AuditGroups),
"audit_group_hash": promptaudit.Config{AuditGroups: req.AuditGroups}.AuditGroupHash(),
- "error": err.Error(),
+ "error_code": "save_config_failed",
}))
promptaudit.LogWarnEvent(
"prompt_audit.config_updated",
@@ -91,15 +111,16 @@ func UpdatePromptAuditConfig(c *gin.Context) {
promptaudit.Field("audit_group_count", len(req.AuditGroups)),
promptaudit.Field("audit_group_hash", promptaudit.Config{AuditGroups: req.AuditGroups}.AuditGroupHash()),
promptaudit.Field("error_code", "save_config_failed"),
- promptaudit.Field("error_kind", err.Error()),
+ promptaudit.Field("error_kind", "config_save_failed"),
)...,
)
- common.ApiErrorMsg(c, err.Error())
+ common.ApiErrorMsg(c, "保存提示词审计配置失败")
return
}
- promptaudit.ClearConfigCache()
recordAdminAuditSuccess(c, "prompt_audit.config.update", "prompt_audit_config", "global", promptAuditAdminAuditDetail(map[string]any{
"enabled": cfg.Enabled,
+ "blocking_enabled": cfg.BlockingEnabled,
+ "config_version": cfg.ConfigVersion,
"endpoint_count": len(cfg.Endpoints),
"scanner_count": len(cfg.Scanners),
"worker_count": cfg.WorkerCount,
@@ -123,6 +144,16 @@ func UpdatePromptAuditConfig(c *gin.Context) {
promptaudit.Field("audit_group_hash", cfg.AuditGroupHash()),
)...,
)
+ promptaudit.LogInfoEvent(
+ "prompt_guard.config_updated",
+ promptAuditLogFields(c,
+ promptaudit.Field("status", "success"),
+ promptaudit.Field("enabled", cfg.Enabled),
+ promptaudit.Field("blocking_enabled", cfg.BlockingEnabled),
+ promptaudit.Field("config_version", cfg.ConfigVersion),
+ promptaudit.Field("updated_by", req.UpdatedBy),
+ )...,
+ )
common.ApiSuccess(c, cfg)
}
diff --git a/ai-gateway/internal/controller/prompt_audit_test.go b/ai-gateway/internal/controller/prompt_audit_test.go
index 76ae4f4c27e2eec3220532fc1dadc562dd2ee9ce..f6e51234283e35a63c77d9ca4a83581c3ee693d8 100644
--- a/ai-gateway/internal/controller/prompt_audit_test.go
+++ b/ai-gateway/internal/controller/prompt_audit_test.go
@@ -21,6 +21,90 @@ import (
"github.com/mt21625457/aicodex/internal/testutil"
)
+type controllerPromptAuditOptionStore struct {
+ values map[string]string
+ getErr error
+ setErr error
+}
+
+func (s *controllerPromptAuditOptionStore) GetOption(_ context.Context, key string) (string, bool, error) {
+ if s.getErr != nil {
+ return "", false, s.getErr
+ }
+ value, ok := s.values[key]
+ return value, ok, nil
+}
+
+func (s *controllerPromptAuditOptionStore) SetOption(_ context.Context, key string, value string) error {
+ if s.setErr != nil {
+ return s.setErr
+ }
+ if s.values == nil {
+ s.values = make(map[string]string)
+ }
+ s.values[key] = value
+ return nil
+}
+
+func withPromptAuditConfigServiceFactory(t *testing.T, store promptaudit.OptionStore) {
+ t.Helper()
+ previous := promptAuditConfigServiceFactory
+ promptAuditConfigServiceFactory = func() *promptaudit.ConfigService {
+ return promptaudit.NewConfigService(store)
+ }
+ t.Cleanup(func() {
+ promptAuditConfigServiceFactory = previous
+ })
+}
+
+func TestPromptAuditConfigAPIRejectsInvalidBlockingCombination(t *testing.T) {
+ withPromptAuditConfigServiceFactory(t, &controllerPromptAuditOptionStore{values: map[string]string{}})
+ ctx, recorder := newPromptAuditRequestContext(t, http.MethodPut, "/api/prompt-audit/config", `{"enabled":false,"blocking_enabled":true,"strategy":"priority"}`)
+
+ UpdatePromptAuditConfig(ctx)
+
+ if recorder.Code != http.StatusBadRequest {
+ t.Fatalf("非法同步阻止组合应返回 400,实际 status=%d body=%s", recorder.Code, recorder.Body.String())
+ }
+ if body := recorder.Body.String(); !strings.Contains(body, promptaudit.PromptGuardRequiresAuditEnabled) {
+ t.Fatalf("非法同步阻止组合应返回稳定错误码,实际 %s", body)
+ }
+}
+
+func TestPromptAuditConfigAPIMasksInternalReadAndSaveErrors(t *testing.T) {
+ const sensitiveInternalError = "postgres://admin:super-secret@db.internal/aicodex"
+
+ t.Run("读取失败", func(t *testing.T) {
+ withPromptAuditConfigServiceFactory(t, &controllerPromptAuditOptionStore{getErr: errors.New(sensitiveInternalError)})
+ ctx, recorder := newPromptAuditRequestContext(t, http.MethodGet, "/api/prompt-audit/config", "")
+
+ GetPromptAuditConfig(ctx)
+
+ body := recorder.Body.String()
+ if strings.Contains(body, sensitiveInternalError) || strings.Contains(body, "super-secret") {
+ t.Fatalf("配置读取错误不得回显内部连接信息: %s", body)
+ }
+ if !strings.Contains(body, "读取提示词审计配置失败") {
+ t.Fatalf("配置读取错误应返回通用消息: %s", body)
+ }
+ })
+
+ t.Run("保存失败", func(t *testing.T) {
+ withPromptAuditConfigServiceFactory(t, &controllerPromptAuditOptionStore{values: map[string]string{}, setErr: errors.New(sensitiveInternalError)})
+ ctx, recorder := newPromptAuditRequestContext(t, http.MethodPut, "/api/prompt-audit/config", `{"enabled":false,"blocking_enabled":false,"strategy":"priority"}`)
+
+ UpdatePromptAuditConfig(ctx)
+
+ body := recorder.Body.String()
+ if strings.Contains(body, sensitiveInternalError) || strings.Contains(body, "super-secret") {
+ t.Fatalf("配置保存错误不得回显内部连接信息: %s", body)
+ }
+ if !strings.Contains(body, "保存提示词审计配置失败") {
+ t.Fatalf("配置保存错误应返回通用消息: %s", body)
+ }
+ })
+}
+
func TestPromptAuditConfigAPIStoresTokenAsSensitiveValue(t *testing.T) {
withPromptAuditControllerTestDB(t, func() {
gin.SetMode(gin.TestMode)
diff --git a/ai-gateway/internal/gatewayadapter/transport/anthropic.go b/ai-gateway/internal/gatewayadapter/transport/anthropic.go
index 5d15c6bd79bc5530094eb43fde9f47645819cb1a..10dce044a23e590534d6d410e8a46929225dae0c 100644
--- a/ai-gateway/internal/gatewayadapter/transport/anthropic.go
+++ b/ai-gateway/internal/gatewayadapter/transport/anthropic.go
@@ -113,6 +113,7 @@ func NewAnthropicGatewayHandler(deps AnthropicGatewayDeps) http.Handler {
v1.Use(gatewaycore.RegisterHTTPAuditPostHook())
v1.Use(middleware.UserConcurrencyLimit())
v1.Use(middleware.ModelRequestRateLimit())
+ v1.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatClaude))
v1.Use(middleware.Distribute())
v1.Use(middleware.PriorityAdmission())
v1.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatClaude))
diff --git a/ai-gateway/internal/gatewayadapter/transport/gemini.go b/ai-gateway/internal/gatewayadapter/transport/gemini.go
index 53e58476a7fc6ec854766cfac995fb5a4254962e..24d0e12eb16b1fff373fc69eb65268718c81e043 100644
--- a/ai-gateway/internal/gatewayadapter/transport/gemini.go
+++ b/ai-gateway/internal/gatewayadapter/transport/gemini.go
@@ -100,6 +100,7 @@ func NewGeminiGatewayHandler(deps GeminiGatewayDeps) http.Handler {
relayRouter.Use(gatewaycore.RegisterHTTPAuditPostHook())
relayRouter.Use(middleware.UserConcurrencyLimit())
relayRouter.Use(middleware.ModelRequestRateLimit())
+ relayRouter.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatGemini))
relayRouter.Use(middleware.Distribute())
relayRouter.Use(middleware.PriorityAdmission())
relayRouter.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatGemini))
diff --git a/ai-gateway/internal/gatewayadapter/transport/jimeng.go b/ai-gateway/internal/gatewayadapter/transport/jimeng.go
index 8e5ef822bda42b100d8376f635c651a606e4611a..7dbc008716c36ac1ba26d022f0156f82c505876c 100644
--- a/ai-gateway/internal/gatewayadapter/transport/jimeng.go
+++ b/ai-gateway/internal/gatewayadapter/transport/jimeng.go
@@ -54,6 +54,7 @@ func NewJimengGatewayHandler(deps JimengGatewayDeps) http.Handler {
jimeng.Use(middleware.JimengRequestConvert())
jimeng.Use(middleware.TokenAuth())
jimeng.Use(middleware.UserConcurrencyLimit())
+ jimeng.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatTask))
jimeng.Use(middleware.Distribute())
jimeng.Use(middleware.PriorityAdmission())
jimeng.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask))
diff --git a/ai-gateway/internal/gatewayadapter/transport/kling.go b/ai-gateway/internal/gatewayadapter/transport/kling.go
index 875dd1157b3cc168da4ee0bfb5036bca98285b55..1c5e91c3deeb39e1e2ddfa2c5c0c2a721819bbba 100644
--- a/ai-gateway/internal/gatewayadapter/transport/kling.go
+++ b/ai-gateway/internal/gatewayadapter/transport/kling.go
@@ -54,6 +54,7 @@ func NewKlingGatewayHandler(deps KlingGatewayDeps) http.Handler {
kling.Use(middleware.KlingRequestConvert())
kling.Use(middleware.TokenAuth())
kling.Use(middleware.UserConcurrencyLimit())
+ kling.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatTask))
kling.Use(middleware.Distribute())
kling.Use(middleware.PriorityAdmission())
kling.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask))
diff --git a/ai-gateway/internal/gatewayadapter/transport/midjourney.go b/ai-gateway/internal/gatewayadapter/transport/midjourney.go
index 216dbf70eda862db47766944e03de967c67a2ef7..5d4f3079b8d71791e2e0c113120159034ac3165b 100644
--- a/ai-gateway/internal/gatewayadapter/transport/midjourney.go
+++ b/ai-gateway/internal/gatewayadapter/transport/midjourney.go
@@ -122,6 +122,7 @@ func registerMidjourneyTransportGroup(group *gin.RouterGroup, deps MidjourneyGat
group.Use(middleware.TokenAuth())
group.Use(middleware.UserConcurrencyLimit())
+ group.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatMjProxy))
group.Use(middleware.Distribute())
group.Use(middleware.PriorityAdmission())
group.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatMjProxy))
diff --git a/ai-gateway/internal/gatewayadapter/transport/openai.go b/ai-gateway/internal/gatewayadapter/transport/openai.go
index d8fd0fc79ad78036a6540bf0c1c1e6b5a7b8fded..ea729a96b965174996e5b6f9df58519bfc1d1d09 100644
--- a/ai-gateway/internal/gatewayadapter/transport/openai.go
+++ b/ai-gateway/internal/gatewayadapter/transport/openai.go
@@ -157,6 +157,7 @@ func NewOpenAIGatewayHandler(deps OpenAIGatewayDeps) http.Handler {
httpRouter.Use(gatewaycore.RegisterHTTPAuditPostHook())
httpRouter.Use(middleware.UserConcurrencyLimit())
httpRouter.Use(middleware.ModelRequestRateLimit())
+ httpRouter.Use(promptaudit.HTTPGuardMiddleware())
httpRouter.Use(middleware.Distribute())
httpRouter.Use(middleware.PriorityAdmission())
httpRouter.Use(promptaudit.HTTPEnqueueMiddleware())
@@ -195,6 +196,7 @@ func NewOpenAIGatewayHandler(deps OpenAIGatewayDeps) http.Handler {
responsesAliasRouter.Use(gatewaycore.RegisterHTTPAuditPostHook())
responsesAliasRouter.Use(middleware.UserConcurrencyLimit())
responsesAliasRouter.Use(middleware.ModelRequestRateLimit())
+ responsesAliasRouter.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatOpenAIResponses))
responsesAliasRouter.Use(middleware.Distribute())
responsesAliasRouter.Use(middleware.PriorityAdmission())
responsesAliasRouter.Use(promptaudit.HTTPEnqueueMiddleware())
diff --git a/ai-gateway/internal/gatewayadapter/transport/suno.go b/ai-gateway/internal/gatewayadapter/transport/suno.go
index 714e282598547c5de444e145e44797ac25d7c4e4..e3532cc3c37ad5a73034a27c4ac9c9dfdd1092db 100644
--- a/ai-gateway/internal/gatewayadapter/transport/suno.go
+++ b/ai-gateway/internal/gatewayadapter/transport/suno.go
@@ -65,6 +65,7 @@ func NewSunoGatewayHandler(deps SunoGatewayDeps) http.Handler {
suno.Use(middleware.SystemPerformanceCheck())
suno.Use(middleware.TokenAuth())
suno.Use(middleware.UserConcurrencyLimit())
+ suno.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatTask))
suno.Use(middleware.Distribute())
suno.Use(middleware.PriorityAdmission())
suno.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask))
diff --git a/ai-gateway/internal/gatewayadapter/transport/task.go b/ai-gateway/internal/gatewayadapter/transport/task.go
index 18f8c1fbdc6b039217fcd5b159a5a7c6e4a49480..9233198c44b17d864fb6243a2ec48a71e24bb2e0 100644
--- a/ai-gateway/internal/gatewayadapter/transport/task.go
+++ b/ai-gateway/internal/gatewayadapter/transport/task.go
@@ -74,6 +74,7 @@ func NewTaskGatewayHandler(deps TaskGatewayDeps) http.Handler {
v1 := engine.Group("/v1", relayMiddlewares...)
v1.Use(middleware.TokenAuth())
v1.Use(middleware.UserConcurrencyLimit())
+ v1.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatTask))
v1.Use(middleware.Distribute())
v1.Use(middleware.PriorityAdmission())
v1.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask))
diff --git a/ai-gateway/internal/gatewayadapter/transport/user_concurrency_order_test.go b/ai-gateway/internal/gatewayadapter/transport/user_concurrency_order_test.go
index f2afd6df93ddac878b88af129ac6ba2965fe1418..ec93e96b6052b4e078754c59fb1ac2302e66a74c 100644
--- a/ai-gateway/internal/gatewayadapter/transport/user_concurrency_order_test.go
+++ b/ai-gateway/internal/gatewayadapter/transport/user_concurrency_order_test.go
@@ -1,6 +1,8 @@
package transport
import (
+ "context"
+ "fmt"
"go/ast"
"go/parser"
"go/token"
@@ -18,9 +20,22 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
"github.com/mt21625457/aicodex/internal/common"
+ "github.com/mt21625457/aicodex/internal/gatewaycore"
+ "github.com/mt21625457/aicodex/internal/service/promptaudit"
"github.com/mt21625457/aicodex/internal/setting"
+ "github.com/mt21625457/aicodex/internal/types"
)
+type transportPromptGuardEvaluator struct {
+ result gatewaycore.PromptGuardResult
+ calls atomic.Int32
+}
+
+func (e *transportPromptGuardEvaluator) Evaluate(context.Context, gatewaycore.PromptGuardInput) gatewaycore.PromptGuardResult {
+ e.calls.Add(1)
+ return e.result
+}
+
func TestTransportExecutionRoutesApplyUserConcurrencyAfterTokenAuthBeforeDistribute(t *testing.T) {
tests := []struct {
fileName string
@@ -58,6 +73,203 @@ func TestTransportExecutionRoutesApplyUserConcurrencyAfterTokenAuthBeforeDistrib
}
}
+func TestTransportPromptGuardRunsBeforeDistributionAndPriorityAdmission(t *testing.T) {
+ tests := []struct {
+ fileName string
+ funcName string
+ want []string
+ }{
+ {fileName: "openai.go", funcName: "NewOpenAIGatewayHandler", want: []string{"ModelRequestRateLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
+ {fileName: "anthropic.go", funcName: "NewAnthropicGatewayHandler", want: []string{"ModelRequestRateLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
+ {fileName: "gemini.go", funcName: "NewGeminiGatewayHandler", want: []string{"ModelRequestRateLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
+ {fileName: "suno.go", funcName: "NewSunoGatewayHandler", want: []string{"UserConcurrencyLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
+ {fileName: "midjourney.go", funcName: "registerMidjourneyTransportGroup", want: []string{"UserConcurrencyLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
+ {fileName: "kling.go", funcName: "NewKlingGatewayHandler", want: []string{"UserConcurrencyLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
+ {fileName: "jimeng.go", funcName: "NewJimengGatewayHandler", want: []string{"UserConcurrencyLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
+ {fileName: "task.go", funcName: "NewTaskGatewayHandler", want: []string{"UserConcurrencyLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.fileName+"/"+tt.funcName, func(t *testing.T) {
+ order := middlewareCallOrderInFunction(t, tt.fileName, tt.funcName)
+ if !containsOrderedMiddlewareSequence(order, tt.want) {
+ t.Fatalf("同步门禁必须位于分流与优先级准入之前,want=%v order=%v", tt.want, order)
+ }
+ })
+ }
+}
+
+func TestTransportPromptGuardBlocksSupported9068ProtocolsBeforeRelay(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ cleanup := setupDirectGatewayAuthDB(t)
+ defer cleanup()
+ seedAdminTokenAndChannel(t, "rawtransportguard", 9201)
+ installTransportPromptGuardConfig(t, true)
+
+ evaluator := &transportPromptGuardEvaluator{result: gatewaycore.PromptGuardResult{
+ Decision: gatewaycore.PromptGuardDecisionBlock,
+ Action: gatewaycore.PromptGuardDecisionBlock,
+ ErrorCode: gatewaycore.PromptGuardErrorBlocked,
+ AllowNextStage: false,
+ }}
+ restoreEvaluator := promptaudit.SetPromptGuardEvaluatorForTesting(evaluator)
+ t.Cleanup(restoreEvaluator)
+
+ tests := []struct {
+ name string
+ newHandler func(*atomic.Bool) http.Handler
+ target string
+ body string
+ marker string
+ }{
+ {
+ name: "OpenAI Chat",
+ newHandler: func(entered *atomic.Bool) http.Handler {
+ return NewOpenAIGatewayHandler(OpenAIGatewayDeps{RelayHTTP: func(c *gin.Context, _ interfaceRelayFormat) { entered.Store(true) }})
+ },
+ target: "/v1/chat/completions",
+ body: `{"model":"gpt-5","messages":[{"role":"user","content":"guard-secret-chat"}]}`,
+ marker: gatewaycore.PromptGuardErrorBlocked,
+ },
+ {
+ name: "OpenAI Responses alias",
+ newHandler: func(entered *atomic.Bool) http.Handler {
+ return NewOpenAIGatewayHandler(OpenAIGatewayDeps{RelayHTTP: func(c *gin.Context, _ interfaceRelayFormat) { entered.Store(true) }})
+ },
+ target: "/responses",
+ body: `{"model":"gpt-5","input":"guard-secret-responses"}`,
+ marker: gatewaycore.PromptGuardErrorBlocked,
+ },
+ {
+ name: "Claude Messages",
+ newHandler: func(_ *atomic.Bool) http.Handler {
+ return NewAnthropicGatewayHandler(AnthropicGatewayDeps{})
+ },
+ target: "/v1/messages",
+ body: `{"model":"claude-sonnet","messages":[{"role":"user","content":"guard-secret-claude"}]}`,
+ marker: `"type":"prompt_guard_blocked"`,
+ },
+ {
+ name: "Gemini",
+ newHandler: func(entered *atomic.Bool) http.Handler {
+ return NewGeminiGatewayHandler(GeminiGatewayDeps{RelayHTTP: func(c *gin.Context, _ interfaceRelayFormat) { entered.Store(true) }})
+ },
+ target: "/v1beta/models/gemini-2.5:streamGenerateContent",
+ body: `{"contents":[{"role":"user","parts":[{"text":"guard-secret-gemini"}]}]}`,
+ marker: `"reason":"prompt_guard_blocked"`,
+ },
+ {
+ name: "text task",
+ newHandler: func(entered *atomic.Bool) http.Handler {
+ return NewTaskGatewayHandler(TaskGatewayDeps{RelayTask: func(c *gin.Context) { entered.Store(true) }})
+ },
+ target: "/v1/videos",
+ body: `{"model":"sora-2","prompt":"guard-secret-task"}`,
+ marker: gatewaycore.PromptGuardErrorBlocked,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var entered atomic.Bool
+ req := httptest.NewRequest(http.MethodPost, tt.target, strings.NewReader(tt.body))
+ req.Header.Set("Authorization", "Bearer sk-rawtransportguard-1")
+ req.Header.Set("Content-Type", gin.MIMEJSON)
+ req = req.WithContext(common.WithRequestEntrypoint(req.Context(), common.RequestEntrypointAI9068))
+ rec := httptest.NewRecorder()
+ tt.newHandler(&entered).ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden || entered.Load() || !strings.Contains(rec.Body.String(), tt.marker) {
+ t.Fatalf("9068 同步门禁未在 relay 前阻止: status=%d entered=%v body=%s", rec.Code, entered.Load(), rec.Body.String())
+ }
+ for _, forbidden := range []string{"guard-secret-", "127.0.0.1:18080"} {
+ if strings.Contains(rec.Body.String(), forbidden) {
+ t.Fatalf("9068 错误响应泄露敏感信息 %q: %s", forbidden, rec.Body.String())
+ }
+ }
+ })
+ }
+ if got := evaluator.calls.Load(); got != int32(len(tests)) {
+ t.Fatalf("每个协议请求必须且仅调用一次同步 Guard: calls=%d want=%d", got, len(tests))
+ }
+}
+
+func TestTransportPromptGuardDisabledKeeps9068AsynchronousAudit(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ cleanup := setupDirectGatewayAuthDB(t)
+ defer cleanup()
+ seedAdminTokenAndChannel(t, "rawtransportobserve", 9202)
+ installTransportPromptGuardConfig(t, false)
+
+ evaluator := &transportPromptGuardEvaluator{result: gatewaycore.PromptGuardResult{
+ Decision: gatewaycore.PromptGuardDecisionUnavailable,
+ ErrorCode: gatewaycore.PromptGuardErrorUnavailable,
+ AllowNextStage: false,
+ }}
+ restoreEvaluator := promptaudit.SetPromptGuardEvaluatorForTesting(evaluator)
+ t.Cleanup(restoreEvaluator)
+ repo := promptaudit.NewMemoryRepository(promptaudit.EntRepository{})
+ restoreDefaults := promptaudit.SetDefaultsForTesting(repo, promptaudit.NewMemoryPayloadStore())
+ t.Cleanup(restoreDefaults)
+
+ var entered atomic.Bool
+ handler := NewOpenAIGatewayHandler(OpenAIGatewayDeps{RelayHTTP: func(c *gin.Context, _ interfaceRelayFormat) {
+ entered.Store(true)
+ c.Status(http.StatusNoContent)
+ }})
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5","messages":[{"role":"user","content":"observe-only"}]}`))
+ req.Header.Set("Authorization", "Bearer sk-rawtransportobserve-1")
+ req.Header.Set("Content-Type", gin.MIMEJSON)
+ req = req.WithContext(common.WithRequestEntrypoint(req.Context(), common.RequestEntrypointAI9068))
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNoContent || !entered.Load() || evaluator.calls.Load() != 0 {
+ t.Fatalf("关闭同步阻止时必须继续原异步链路: status=%d entered=%v guard_calls=%d body=%s", rec.Code, entered.Load(), evaluator.calls.Load(), rec.Body.String())
+ }
+ deadline := time.Now().Add(time.Second)
+ for {
+ active, err := repo.CountActiveJobs(context.Background())
+ if err != nil {
+ t.Fatalf("读取异步审计队列失败: %v", err)
+ }
+ if active == 1 {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("异步只审计模式未创建任务: active=%d", active)
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
+// interfaceRelayFormat 是测试回调中 types.RelayFormat 的本地别名,避免与标准库类型混淆。
+type interfaceRelayFormat = types.RelayFormat
+
+func installTransportPromptGuardConfig(t *testing.T, blocking bool) {
+ t.Helper()
+ common.OptionMapRWMutex.Lock()
+ hadMap := common.OptionMap != nil
+ if common.OptionMap == nil {
+ common.OptionMap = map[string]string{}
+ }
+ previous, hadValue := common.OptionMap[promptaudit.ConfigOptionKey]
+ common.OptionMap[promptaudit.ConfigOptionKey] = fmt.Sprintf(`{"enabled":true,"blocking_enabled":%t,"store_pass_events":false,"strategy":"priority","worker_count":1,"queue_capacity":100,"scanners":["Jailbreak"],"audit_group_mode":"all","endpoints":[{"id":"guard","name":"guard","base_url":"http://127.0.0.1:18080","timeout_ms":1000,"input_limit":1024,"weight":100,"enabled":true}],"config_version":7}`, blocking)
+ common.OptionMapRWMutex.Unlock()
+ promptaudit.ClearConfigCache()
+ t.Cleanup(func() {
+ common.OptionMapRWMutex.Lock()
+ if hadValue {
+ common.OptionMap[promptaudit.ConfigOptionKey] = previous
+ } else {
+ delete(common.OptionMap, promptaudit.ConfigOptionKey)
+ if !hadMap && len(common.OptionMap) == 0 {
+ common.OptionMap = nil
+ }
+ }
+ common.OptionMapRWMutex.Unlock()
+ promptaudit.ClearConfigCache()
+ })
+}
+
func TestTaskLikeTransportsRejectExceededUserConcurrencyBeforeRelayHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
cleanup := setupDirectGatewayAuthDB(t)
@@ -222,7 +434,7 @@ func middlewareCallOrderInFunction(t *testing.T, fileName string, funcName strin
return true
}
switch selector.Sel.Name {
- case "TokenAuth", "UserConcurrencyLimit", "Distribute":
+ case "TokenAuth", "UserConcurrencyLimit", "ModelRequestRateLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission":
order = append(order, selector.Sel.Name)
}
return true
@@ -230,6 +442,23 @@ func middlewareCallOrderInFunction(t *testing.T, fileName string, funcName strin
return order
}
+func containsOrderedMiddlewareSequence(items []string, sequence []string) bool {
+ if len(sequence) == 0 {
+ return true
+ }
+ matched := 0
+ for _, item := range items {
+ if item != sequence[matched] {
+ continue
+ }
+ matched++
+ if matched == len(sequence) {
+ return true
+ }
+ }
+ return false
+}
+
func firstIndex(items []string, target string) int {
for i, item := range items {
if item == target {
diff --git a/ai-gateway/internal/relay/ws_responses.go b/ai-gateway/internal/relay/ws_responses.go
index 885822ed8deb240a0c4c83c9f36f3a1d60455b65..0cca8b2add6028411eb314c98eb1fc370954dc2f 100644
--- a/ai-gateway/internal/relay/ws_responses.go
+++ b/ai-gateway/internal/relay/ws_responses.go
@@ -232,6 +232,8 @@ func IsWebSocketUpgradeRequest(r *http.Request) bool {
}
func WsResponsesHelper(c *gin.Context) *types.AICodexError {
+ // 测试和灰度会替换 hook;请求开始时固定函数快照,避免长连接结束阶段与 hook 回收并发读写。
+ recordChannelAffinity := responsesWSRecordChannelAffinity
if !IsWebSocketUpgradeRequest(c.Request) {
apiErr := types.NewErrorWithStatusCode(
errors.New("WebSocket upgrade required (Upgrade: websocket)"),
@@ -294,6 +296,19 @@ func WsResponsesHelper(c *gin.Context) *types.AICodexError {
logResponsesWSSetupFailed(c, nil, "first_message", firstErr, coderws.StatusPolicyViolation, "invalid first response.create payload", nil)
return firstErr
}
+ firstGuardCheck := promptaudit.EvaluatePromptGuardBody(c, types.RelayFormatOpenAIResponsesWS, "/v1/responses", firstMessage, "first_turn", false)
+ if !firstGuardCheck.Allowed {
+ closeCode := coderws.StatusTryAgainLater
+ if firstGuardCheck.ErrorCode == "prompt_guard_blocked" {
+ closeCode = coderws.StatusCode(4403)
+ }
+ responsesWSCloseClient(clientConn, closeCode, firstGuardCheck.ErrorCode)
+ apiErr := promptGuardAICodexError(firstGuardCheck)
+ logResponsesWSSetupFailed(c, nil, "prompt_guard_first_turn", apiErr, closeCode, firstGuardCheck.ErrorCode, map[string]any{
+ "model": requestModel,
+ })
+ return apiErr
+ }
prepared, prepErr := dataplaneopenai.PrepareWSForwarding(&dataplaneopenai.WSPrepareRequest{
RequestModel: requestModel,
@@ -355,7 +370,6 @@ func WsResponsesHelper(c *gin.Context) *types.AICodexError {
return apiErr
}
promptaudit.MaybeEnqueueTurnFromGateway(c, types.RelayFormatOpenAIResponsesWS, firstMessage)
-
dialCtx := c.Request.Context()
cancelDial := func() {}
if runtimeSettings.UpstreamDialTimeout > 0 {
@@ -461,6 +475,10 @@ func WsResponsesHelper(c *gin.Context) *types.AICodexError {
if !isResponsesWSResponseCreatePayload(payload) {
return nil
}
+ guardCheck := promptaudit.EvaluatePromptGuardBody(c, types.RelayFormatOpenAIResponsesWS, "/v1/responses", payload, "subsequent_turn", false)
+ if !guardCheck.Allowed {
+ return promptGuardAICodexError(guardCheck)
+ }
if apiErr := relaycommon.ValidateOpenAIPriorityMode(
gjson.GetBytes(payload, "service_tier").String(),
channelOtherSettings.IsOpenAIPriorityAllowed(),
@@ -524,7 +542,7 @@ func WsResponsesHelper(c *gin.Context) *types.AICodexError {
logger.LogWarnEvent(c.Request.Context(), event, fields)
},
RecordChannelAffinity: func(channelID int) {
- responsesWSRecordChannelAffinity(c, channelID)
+ recordChannelAffinity(c, channelID)
},
})
}
@@ -841,6 +859,14 @@ func mapResponsesWSErrorToCloseCode(err error, stage string, upstreamStatusCode
case errors.Is(err, context.DeadlineExceeded):
return coderws.StatusTryAgainLater, "upstream timeout"
case errors.As(err, &apiErr) && apiErr != nil:
+ switch apiErr.GetErrorCode() {
+ case types.ErrorCodePromptGuardBlocked:
+ return coderws.StatusCode(4403), string(types.ErrorCodePromptGuardBlocked)
+ case types.ErrorCodePromptGuardUnavailable:
+ return coderws.StatusTryAgainLater, string(types.ErrorCodePromptGuardUnavailable)
+ case types.ErrorCodePromptGuardInvalidResponse:
+ return coderws.StatusTryAgainLater, string(types.ErrorCodePromptGuardInvalidResponse)
+ }
if apiErr.GetErrorCode() == types.ErrorCodeSubscriptionPolicyMissing {
return coderws.StatusPolicyViolation, string(types.ErrorCodeSubscriptionPolicyMissing)
}
@@ -874,6 +900,22 @@ func mapResponsesWSErrorToCloseCode(err error, stage string, upstreamStatusCode
return coderws.StatusInternalError, "upstream websocket proxy failed"
}
+func promptGuardAICodexError(check promptaudit.PromptGuardCheck) *types.AICodexError {
+ code := types.ErrorCodePromptGuardUnavailable
+ message := "提示词安全服务暂时不可用,请稍后重试"
+ if check.ErrorCode == "prompt_guard_blocked" {
+ code = types.ErrorCodePromptGuardBlocked
+ message = "请求因提示词安全策略被阻止"
+ } else if check.ErrorCode == "prompt_guard_invalid_response" {
+ code = types.ErrorCodePromptGuardInvalidResponse
+ }
+ status := check.StatusCode
+ if status == 0 {
+ status = http.StatusServiceUnavailable
+ }
+ return types.NewErrorWithStatusCode(errors.New(message), code, status, types.ErrOptionWithSkipRetry())
+}
+
func mapGatewayResponsesWSErrorToCloseCode(apiErr *types.AICodexError) (coderws.StatusCode, string, bool) {
if apiErr == nil {
return 0, "", false
diff --git a/ai-gateway/internal/router/relay-router.go b/ai-gateway/internal/router/relay-router.go
index 6c20b82fdacf19dfa08492fac5ebd2c95cc2722d..6ecf9e3a6b931f8d7c3e97541a83d2b2ef54020e 100644
--- a/ai-gateway/internal/router/relay-router.go
+++ b/ai-gateway/internal/router/relay-router.go
@@ -8,6 +8,7 @@ import (
"github.com/mt21625457/aicodex/internal/controller"
"github.com/mt21625457/aicodex/internal/middleware"
"github.com/mt21625457/aicodex/internal/relay"
+ "github.com/mt21625457/aicodex/internal/service/promptaudit"
"github.com/mt21625457/aicodex/internal/types"
"github.com/gin-gonic/gin"
@@ -103,6 +104,7 @@ func SetRelayRouter(router *gin.Engine) {
httpRouter.Use(middleware.HTTPAuditTrackMultiIP())
httpRouter.Use(middleware.UserConcurrencyLimit())
httpRouter.Use(middleware.ModelRequestRateLimit())
+ httpRouter.Use(promptaudit.HTTPGuardMiddleware())
httpRouter.Use(middleware.Distribute())
httpRouter.Use(middleware.PriorityAdmission())
httpRouter.Use(middleware.HTTPAudit())
@@ -190,6 +192,7 @@ func SetRelayRouter(router *gin.Engine) {
responsesAliasRouter.Use(middleware.HTTPAuditTrackMultiIP())
responsesAliasRouter.Use(middleware.UserConcurrencyLimit())
responsesAliasRouter.Use(middleware.ModelRequestRateLimit())
+ responsesAliasRouter.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatOpenAIResponses))
responsesAliasRouter.Use(middleware.Distribute())
responsesAliasRouter.Use(middleware.PriorityAdmission())
responsesAliasRouter.Use(middleware.HTTPAudit())
@@ -208,7 +211,7 @@ func SetRelayRouter(router *gin.Engine) {
relaySunoRouter := router.Group("/suno", relayMiddlewares...)
relaySunoRouter.Use(middleware.SystemPerformanceCheck())
- relaySunoRouter.Use(middleware.TokenAuth(), middleware.UserConcurrencyLimit(), middleware.Distribute(), middleware.PriorityAdmission())
+ relaySunoRouter.Use(middleware.TokenAuth(), middleware.UserConcurrencyLimit(), promptaudit.HTTPGuardMiddleware(types.RelayFormatTask), middleware.Distribute(), middleware.PriorityAdmission(), promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask))
{
relaySunoRouter.POST("/submit/:action", controller.RelayTask)
relaySunoRouter.POST("/fetch", controller.RelayTask)
@@ -221,6 +224,7 @@ func SetRelayRouter(router *gin.Engine) {
relayGeminiRouter.Use(middleware.HTTPAuditTrackMultiIP())
relayGeminiRouter.Use(middleware.UserConcurrencyLimit())
relayGeminiRouter.Use(middleware.ModelRequestRateLimit())
+ relayGeminiRouter.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatGemini))
relayGeminiRouter.Use(middleware.Distribute())
relayGeminiRouter.Use(middleware.PriorityAdmission())
relayGeminiRouter.Use(middleware.HTTPAudit())
@@ -237,7 +241,7 @@ func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) {
imageRoute.Use(middleware.UserAuth())
imageRoute.GET("/image/:id", relay.RelayMidjourneyImage)
- relayMjRouter.Use(middleware.TokenAuth(), middleware.UserConcurrencyLimit(), middleware.Distribute(), middleware.PriorityAdmission())
+ relayMjRouter.Use(middleware.TokenAuth(), middleware.UserConcurrencyLimit(), promptaudit.HTTPGuardMiddleware(types.RelayFormatMjProxy), middleware.Distribute(), middleware.PriorityAdmission(), promptaudit.HTTPEnqueueMiddleware(types.RelayFormatMjProxy))
{
relayMjRouter.POST("/submit/action", controller.RelayMidjourney)
relayMjRouter.POST("/submit/shorten", controller.RelayMidjourney)
diff --git a/ai-gateway/internal/router/video-router.go b/ai-gateway/internal/router/video-router.go
index b6a0168773f73df7995d19f4be63df4c3f9bce4b..29d55934963930d42700eaa8dffb462457b49bee 100644
--- a/ai-gateway/internal/router/video-router.go
+++ b/ai-gateway/internal/router/video-router.go
@@ -3,13 +3,22 @@ package router
import (
"github.com/mt21625457/aicodex/internal/controller"
"github.com/mt21625457/aicodex/internal/middleware"
+ "github.com/mt21625457/aicodex/internal/service/promptaudit"
+ "github.com/mt21625457/aicodex/internal/types"
"github.com/gin-gonic/gin"
)
func SetVideoRouter(router *gin.Engine) {
videoV1Router := router.Group("/v1")
- videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute(), middleware.PriorityAdmission())
+ videoV1Router.Use(
+ middleware.TokenAuth(),
+ middleware.UserConcurrencyLimit(),
+ promptaudit.HTTPGuardMiddleware(types.RelayFormatTask),
+ middleware.Distribute(),
+ middleware.PriorityAdmission(),
+ promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask),
+ )
{
videoV1Router.GET("/videos/:task_id/content", controller.VideoProxy)
videoV1Router.POST("/video/generations", controller.RelayTask)
@@ -24,7 +33,15 @@ func SetVideoRouter(router *gin.Engine) {
}
klingV1Router := router.Group("/kling/v1")
- klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute(), middleware.PriorityAdmission())
+ klingV1Router.Use(
+ middleware.KlingRequestConvert(),
+ middleware.TokenAuth(),
+ middleware.UserConcurrencyLimit(),
+ promptaudit.HTTPGuardMiddleware(types.RelayFormatTask),
+ middleware.Distribute(),
+ middleware.PriorityAdmission(),
+ promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask),
+ )
{
klingV1Router.POST("/videos/text2video", controller.RelayTask)
klingV1Router.POST("/videos/image2video", controller.RelayTask)
@@ -34,7 +51,15 @@ func SetVideoRouter(router *gin.Engine) {
// Jimeng official API routes - direct mapping to official API format
jimengOfficialGroup := router.Group("jimeng")
- jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute(), middleware.PriorityAdmission())
+ jimengOfficialGroup.Use(
+ middleware.JimengRequestConvert(),
+ middleware.TokenAuth(),
+ middleware.UserConcurrencyLimit(),
+ promptaudit.HTTPGuardMiddleware(types.RelayFormatTask),
+ middleware.Distribute(),
+ middleware.PriorityAdmission(),
+ promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask),
+ )
{
// Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31
jimengOfficialGroup.POST("/", controller.RelayTask)
diff --git a/ai-gateway/internal/service/promptaudit/client.go b/ai-gateway/internal/service/promptaudit/client.go
index 91e18f5fb4ebb24a8a42c72826506f6bf5152493..941c54c35378d50d71bf49b8c62906bab2663cb3 100644
--- a/ai-gateway/internal/service/promptaudit/client.go
+++ b/ai-gateway/internal/service/promptaudit/client.go
@@ -47,15 +47,8 @@ func newLLMGuardError(code string, message string, retryable bool, statusCode in
}
func readSmallResponseBody(body io.Reader) string {
- if body == nil {
- return ""
- }
- data, _ := io.ReadAll(io.LimitReader(body, 4096))
- message := strings.TrimSpace(string(data))
- if message == "" {
- return "审计 API 返回非成功状态"
- }
- return message
+ _ = body
+ return "Guard API 返回非成功状态"
}
func firstNonEmptyString(values ...string) string {
diff --git a/ai-gateway/internal/service/promptaudit/config.go b/ai-gateway/internal/service/promptaudit/config.go
index db835a3704607700a65dad0b0b3cd7cbf4b8e6e0..f15a361f91441ed98f3f6edf4f1cffb7f1530ab1 100644
--- a/ai-gateway/internal/service/promptaudit/config.go
+++ b/ai-gateway/internal/service/promptaudit/config.go
@@ -5,13 +5,13 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
- "errors"
"fmt"
- "net/url"
"os"
"slices"
"strconv"
"strings"
+ "sync"
+ "sync/atomic"
"time"
"github.com/mt21625457/aicodex/internal/common"
@@ -20,6 +20,12 @@ import (
const ConfigOptionKey = "PromptAuditConfigJSON"
+const (
+ PromptGuardRequiresAuditEnabled = "prompt_guard_requires_audit_enabled"
+ PromptGuardInvalidStrategy = "prompt_guard_invalid_strategy"
+ PromptAuditConfigInvalid = "prompt_audit_config_invalid"
+)
+
const (
AuditGroupModeAll = "all"
AuditGroupModeSelected = "selected"
@@ -32,12 +38,10 @@ const (
)
var (
- allowedStrategies = map[string]struct{}{
- "priority": {},
- "weighted": {},
- "shadow": {},
- }
- codeContentScanners = map[string]struct{}{
+ configSaveMu sync.Mutex
+ strategyMigrationLogged atomic.Bool
+ allowedStrategies = map[string]struct{}{"priority": {}}
+ codeContentScanners = map[string]struct{}{
"bancode": {},
"code": {},
}
@@ -117,6 +121,7 @@ type EndpointConfig struct {
type Config struct {
Enabled bool `json:"enabled"`
+ BlockingEnabled bool `json:"blocking_enabled"`
StorePassEvents bool `json:"store_pass_events"`
Strategy string `json:"strategy"`
WorkerCount int `json:"worker_count"`
@@ -125,6 +130,9 @@ type Config struct {
AuditGroupMode string `json:"audit_group_mode"`
AuditGroups []string `json:"audit_groups"`
Endpoints []EndpointConfig `json:"endpoints"`
+ ConfigVersion int64 `json:"config_version"`
+ UpdatedBy int `json:"updated_by,omitempty"`
+ ChangeSummary string `json:"change_summary,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -147,6 +155,7 @@ type EndpointInput struct {
type SaveConfigRequest struct {
Enabled bool `json:"enabled"`
+ BlockingEnabled bool `json:"blocking_enabled"`
StorePassEvents bool `json:"store_pass_events"`
Strategy string `json:"strategy"`
WorkerCount int `json:"worker_count"`
@@ -155,6 +164,8 @@ type SaveConfigRequest struct {
AuditGroupMode string `json:"audit_group_mode"`
AuditGroups []string `json:"audit_groups"`
Endpoints []EndpointInput `json:"endpoints"`
+ UpdatedBy int `json:"-"`
+ ChangeReason string `json:"change_reason"`
}
type EndpointPublic struct {
@@ -176,6 +187,7 @@ type EndpointPublic struct {
type PublicConfig struct {
Enabled bool `json:"enabled"`
+ BlockingEnabled bool `json:"blocking_enabled"`
StorePassEvents bool `json:"store_pass_events"`
Strategy string `json:"strategy"`
WorkerCount int `json:"worker_count"`
@@ -184,11 +196,15 @@ type PublicConfig struct {
AuditGroupMode string `json:"audit_group_mode"`
AuditGroups []string `json:"audit_groups"`
Endpoints []EndpointPublic `json:"endpoints"`
+ ConfigVersion int64 `json:"config_version"`
+ UpdatedBy int `json:"updated_by,omitempty"`
+ ChangeSummary string `json:"change_summary,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type storedConfig struct {
Enabled bool `json:"enabled"`
+ BlockingEnabled bool `json:"blocking_enabled,omitempty"`
StorePassEvents bool `json:"store_pass_events"`
Strategy string `json:"strategy"`
WorkerCount int `json:"worker_count"`
@@ -197,9 +213,25 @@ type storedConfig struct {
AuditGroupMode string `json:"audit_group_mode,omitempty"`
AuditGroups []string `json:"audit_groups,omitempty"`
Endpoints []storedEndpoint `json:"endpoints"`
+ ConfigVersion int64 `json:"config_version,omitempty"`
+ UpdatedBy int `json:"updated_by,omitempty"`
+ ChangeSummary string `json:"change_summary,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
+// ConfigValidationError 为控制面提供稳定、可测试的配置错误码。
+type ConfigValidationError struct {
+ Code string
+ Message string
+}
+
+func (e *ConfigValidationError) Error() string {
+ if e == nil {
+ return ""
+ }
+ return e.Message
+}
+
type storedEndpoint struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -218,14 +250,16 @@ type storedEndpoint struct {
func DefaultConfig() Config {
cfg := Config{
- Enabled: false,
- Strategy: "priority",
- WorkerCount: 4,
- QueueCapacity: 10000,
- Scanners: defaultOpenAICompatibleScanners(),
- AuditGroupMode: AuditGroupModeAll,
- AuditGroups: []string{},
- Endpoints: []EndpointConfig{},
+ Enabled: false,
+ BlockingEnabled: false,
+ Strategy: "priority",
+ WorkerCount: 4,
+ QueueCapacity: 10000,
+ Scanners: defaultOpenAICompatibleScanners(),
+ AuditGroupMode: AuditGroupModeAll,
+ AuditGroups: []string{},
+ Endpoints: []EndpointConfig{},
+ ConfigVersion: 1,
}
applyEnvDefaults(&cfg)
return cfg
@@ -255,6 +289,8 @@ func (s *ConfigService) Public(ctx context.Context) (PublicConfig, error) {
}
func (s *ConfigService) Save(ctx context.Context, req SaveConfigRequest) (PublicConfig, error) {
+ configSaveMu.Lock()
+ defer configSaveMu.Unlock()
current, err := s.Load(ctx)
if err != nil {
return PublicConfig{}, err
@@ -274,9 +310,22 @@ func (s *ConfigService) Save(ctx context.Context, req SaveConfigRequest) (Public
if err := s.store.SetOption(ctx, ConfigOptionKey, string(body)); err != nil {
return PublicConfig{}, err
}
+ if configStorePublishesRuntime(s.store) {
+ installConfigSnapshot(cfg)
+ publishConfigInvalidation(ctx, cfg.ConfigVersion)
+ }
return cfg.Public(), nil
}
+func configStorePublishesRuntime(store OptionStore) bool {
+ switch store.(type) {
+ case ModelOptionStore, *ModelOptionStore:
+ return true
+ default:
+ return false
+ }
+}
+
func (c Config) Public() PublicConfig {
endpoints := make([]EndpointPublic, 0, len(c.Endpoints))
for _, endpoint := range c.Endpoints {
@@ -304,6 +353,7 @@ func (c Config) Public() PublicConfig {
}
return PublicConfig{
Enabled: c.Enabled,
+ BlockingEnabled: c.Enabled && c.BlockingEnabled,
StorePassEvents: c.StorePassEvents,
Strategy: c.Strategy,
WorkerCount: c.WorkerCount,
@@ -312,19 +362,35 @@ func (c Config) Public() PublicConfig {
AuditGroupMode: normalizeAuditGroupMode(c.AuditGroupMode),
AuditGroups: normalizeAuditGroups(c.AuditGroups),
Endpoints: endpoints,
+ ConfigVersion: normalizeConfigVersion(c.ConfigVersion),
+ UpdatedBy: c.UpdatedBy,
+ ChangeSummary: c.ChangeSummary,
UpdatedAt: c.UpdatedAt,
}
}
func configFromStorage(stored storedConfig) (Config, error) {
+ storedStrategy := strings.ToLower(strings.TrimSpace(stored.Strategy))
+ if storedStrategy != "" && storedStrategy != "priority" && strategyMigrationLogged.CompareAndSwap(false, true) {
+ LogWarnEvent(
+ "prompt_guard.config_loaded",
+ Field("status", "migrated"),
+ Field("error_code", "historical_strategy_migrated"),
+ Field("strategy", "priority"),
+ )
+ }
cfg := Config{
Enabled: stored.Enabled,
+ BlockingEnabled: stored.Enabled && stored.BlockingEnabled,
StorePassEvents: stored.StorePassEvents,
Strategy: normalizeStrategy(stored.Strategy),
WorkerCount: normalizeWorkerCount(stored.WorkerCount),
QueueCapacity: normalizeQueueCapacity(stored.QueueCapacity),
AuditGroupMode: normalizeAuditGroupMode(stored.AuditGroupMode),
AuditGroups: normalizeAuditGroups(stored.AuditGroups),
+ ConfigVersion: normalizeConfigVersion(stored.ConfigVersion),
+ UpdatedBy: stored.UpdatedBy,
+ ChangeSummary: strings.TrimSpace(stored.ChangeSummary),
UpdatedAt: stored.UpdatedAt,
}
for _, endpoint := range stored.Endpoints {
@@ -365,6 +431,7 @@ func configFromStorage(stored storedConfig) (Config, error) {
func configToStorage(cfg Config) (storedConfig, error) {
stored := storedConfig{
Enabled: cfg.Enabled,
+ BlockingEnabled: cfg.Enabled && cfg.BlockingEnabled,
StorePassEvents: cfg.StorePassEvents,
Strategy: normalizeStrategy(cfg.Strategy),
WorkerCount: normalizeWorkerCount(cfg.WorkerCount),
@@ -373,6 +440,9 @@ func configToStorage(cfg Config) (storedConfig, error) {
AuditGroupMode: normalizeAuditGroupMode(cfg.AuditGroupMode),
AuditGroups: normalizeAuditGroups(cfg.AuditGroups),
Endpoints: make([]storedEndpoint, 0, len(cfg.Endpoints)),
+ ConfigVersion: normalizeConfigVersion(cfg.ConfigVersion),
+ UpdatedBy: cfg.UpdatedBy,
+ ChangeSummary: strings.TrimSpace(cfg.ChangeSummary),
UpdatedAt: cfg.UpdatedAt,
}
for _, endpoint := range cfg.Endpoints {
@@ -400,14 +470,34 @@ func configToStorage(cfg Config) (storedConfig, error) {
}
func normalizeSaveRequest(req SaveConfigRequest, current Config) (Config, error) {
+ if !req.Enabled && req.BlockingEnabled {
+ return Config{}, &ConfigValidationError{
+ Code: PromptGuardRequiresAuditEnabled,
+ Message: "启用同步阻止前必须先启用提示词审计",
+ }
+ }
+ strategy := strings.ToLower(strings.TrimSpace(req.Strategy))
+ if strategy == "" {
+ strategy = "priority"
+ }
+ if _, ok := allowedStrategies[strategy]; !ok {
+ return Config{}, &ConfigValidationError{
+ Code: PromptGuardInvalidStrategy,
+ Message: "提示词审计调度策略仅支持 priority",
+ }
+ }
cfg := Config{
Enabled: req.Enabled,
+ BlockingEnabled: req.Enabled && req.BlockingEnabled,
StorePassEvents: req.StorePassEvents,
- Strategy: normalizeStrategy(req.Strategy),
+ Strategy: strategy,
WorkerCount: normalizeWorkerCount(req.WorkerCount),
QueueCapacity: normalizeQueueCapacity(req.QueueCapacity),
AuditGroupMode: normalizeAuditGroupMode(req.AuditGroupMode),
AuditGroups: normalizeAuditGroups(req.AuditGroups),
+ ConfigVersion: normalizeConfigVersion(current.ConfigVersion) + 1,
+ UpdatedBy: req.UpdatedBy,
+ ChangeSummary: buildConfigChangeSummary(req, current),
UpdatedAt: time.Now().UTC(),
}
if strings.TrimSpace(req.AuditGroupMode) == "" {
@@ -426,7 +516,10 @@ func normalizeSaveRequest(req SaveConfigRequest, current Config) (Config, error)
cfg.Endpoints = append(cfg.Endpoints, endpoint)
}
if cfg.Enabled && !hasEnabledEndpointWithScanURL(cfg.Endpoints) {
- return Config{}, errors.New("启用提示词审计前至少需要一个启用且配置了 Base URL 的 OpenAI 兼容审计池")
+ return Config{}, &ConfigValidationError{
+ Code: PromptAuditConfigInvalid,
+ Message: "启用提示词审计前至少需要一个启用且配置了 Base URL 的 OpenAI 兼容审计池",
+ }
}
cfg.Scanners = normalizeScanners(req.Scanners)
return cfg, nil
@@ -454,11 +547,16 @@ func normalizeEndpointInput(input EndpointInput, index int, currentByID map[stri
}
scanURL = normalizeOpenAIChatCompletionsURL(firstNonEmptyString(baseURL, scanURL))
if baseURL == "" {
- return EndpointConfig{}, errors.New("OpenAI 兼容审计池 Base URL 不能为空")
+ return EndpointConfig{}, &ConfigValidationError{
+ Code: PromptAuditConfigInvalid,
+ Message: "OpenAI 兼容审计池 Base URL 不能为空",
+ }
}
- parsed, err := url.Parse(baseURL)
- if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
- return EndpointConfig{}, fmt.Errorf("OpenAI 兼容审计 Base URL 无效: %s", baseURL)
+ if err := validateGuardBaseURL(baseURL); err != nil {
+ return EndpointConfig{}, &ConfigValidationError{
+ Code: PromptAuditConfigInvalid,
+ Message: err.Error(),
+ }
}
if model == "" && hasExisting {
model = existing.Model
@@ -512,6 +610,37 @@ func normalizeStrategy(value string) string {
return "priority"
}
+func normalizeConfigVersion(value int64) int64 {
+ if value < 1 {
+ return 1
+ }
+ return value
+}
+
+func buildConfigChangeSummary(req SaveConfigRequest, current Config) string {
+ reason := strings.TrimSpace(req.ChangeReason)
+ if len([]rune(reason)) > 120 {
+ reason = string([]rune(reason)[:120])
+ }
+ mode := "异步只审计"
+ if req.Enabled && req.BlockingEnabled {
+ mode = "同步阻止"
+ } else if !req.Enabled {
+ mode = "关闭审计"
+ }
+ previousMode := "异步只审计"
+ if current.Enabled && current.BlockingEnabled {
+ previousMode = "同步阻止"
+ } else if !current.Enabled {
+ previousMode = "关闭审计"
+ }
+ summary := fmt.Sprintf("模式:%s→%s;审计池:%d;类别:%d", previousMode, mode, len(req.Endpoints), len(req.Scanners))
+ if reason != "" {
+ summary += ";原因:" + reason
+ }
+ return summary
+}
+
func normalizeWorkerCount(value int) int {
if value < 1 {
return 4
@@ -709,7 +838,7 @@ func canonicalScannerNameForProtocol(_ string, value string) (string, bool) {
}
func normalizeScannerKey(value string) string {
- return strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(value)))
+ return strings.NewReplacer("_", "", "-", "", " ", "", "&", "and").Replace(strings.ToLower(strings.TrimSpace(value)))
}
func hasEnabledEndpointWithScanURL(endpoints []EndpointConfig) bool {
for _, endpoint := range endpoints {
@@ -737,6 +866,9 @@ func applyEnvDefaults(cfg *Config) {
if raw := strings.TrimSpace(os.Getenv("PROMPT_AUDIT_ENABLED")); raw != "" {
cfg.Enabled = parseEnvBool(raw, cfg.Enabled)
}
+ if raw := strings.TrimSpace(os.Getenv("PROMPT_AUDIT_BLOCKING_ENABLED")); raw != "" {
+ cfg.BlockingEnabled = cfg.Enabled && parseEnvBool(raw, cfg.BlockingEnabled)
+ }
if raw := strings.TrimSpace(os.Getenv("PROMPT_AUDIT_STORE_PASS_EVENTS")); raw != "" {
cfg.StorePassEvents = parseEnvBool(raw, cfg.StorePassEvents)
}
diff --git a/ai-gateway/internal/service/promptaudit/config_test.go b/ai-gateway/internal/service/promptaudit/config_test.go
index b9f766d6b289c7f1eb2ab3a4f7dfb2e3fb32e0c7..b20070937a3c6c18a55ea1ea979902b6c17c0a13 100644
--- a/ai-gateway/internal/service/promptaudit/config_test.go
+++ b/ai-gateway/internal/service/promptaudit/config_test.go
@@ -66,6 +66,7 @@ func clearPromptAuditEnv(t *testing.T) {
t.Helper()
for _, key := range []string{
"PROMPT_AUDIT_ENABLED",
+ "PROMPT_AUDIT_BLOCKING_ENABLED",
"PROMPT_AUDIT_STORE_PASS_EVENTS",
"PROMPT_AUDIT_STRATEGY",
"PROMPT_AUDIT_WORKER_COUNT",
@@ -153,8 +154,8 @@ func TestConfigServiceLoadsHotOptionMapWithoutDatabaseAndMasksToken(t *testing.T
if err != nil {
t.Fatalf("Load() should use common.OptionMap without touching DB: %v", err)
}
- if !cfg.Enabled || cfg.Strategy != "weighted" || cfg.AuditGroupMode != AuditGroupModeSelected {
- t.Fatalf("hot config not preserved: %+v", cfg)
+ if !cfg.Enabled || cfg.Strategy != "priority" || cfg.AuditGroupMode != AuditGroupModeSelected {
+ t.Fatalf("历史调度策略应迁移为 priority: %+v", cfg)
}
if len(cfg.Endpoints) != 1 {
t.Fatalf("expected one hot endpoint, got %#v", cfg.Endpoints)
@@ -272,6 +273,7 @@ func TestConfigServiceSaveRejectsInvalidEndpointAndCanClearExistingToken(t *test
func TestDefaultConfigAppliesPromptAuditEnvironmentDefaults(t *testing.T) {
clearPromptAuditEnv(t)
t.Setenv("PROMPT_AUDIT_ENABLED", "true")
+ t.Setenv("PROMPT_AUDIT_BLOCKING_ENABLED", "true")
t.Setenv("PROMPT_AUDIT_STORE_PASS_EVENTS", "true")
t.Setenv("PROMPT_AUDIT_STRATEGY", "weighted")
t.Setenv("PROMPT_AUDIT_WORKER_COUNT", "2")
@@ -283,7 +285,7 @@ func TestDefaultConfigAppliesPromptAuditEnvironmentDefaults(t *testing.T) {
cfg := DefaultConfig()
- if !cfg.Enabled || !cfg.StorePassEvents || cfg.Strategy != "weighted" {
+ if !cfg.Enabled || !cfg.BlockingEnabled || !cfg.StorePassEvents || cfg.Strategy != "priority" {
t.Fatalf("env flags not applied: %+v", cfg)
}
if cfg.WorkerCount != 2 || cfg.QueueCapacity != 250 {
@@ -508,7 +510,7 @@ func TestConfigServiceSaveEncryptsTokenAndPublicResponseMasksToken(t *testing.T)
publicCfg, err := svc.Save(context.Background(), SaveConfigRequest{
Enabled: true,
- Strategy: "weighted",
+ Strategy: "priority",
WorkerCount: 96,
QueueCapacity: 100000,
Scanners: []string{"Jailbreak", "Jailbreak", "PII"},
@@ -662,7 +664,7 @@ func TestDefaultConfigAppliesPromptAuditEnvOverrides(t *testing.T) {
cfg := DefaultConfig()
- if !cfg.Enabled || !cfg.StorePassEvents || cfg.Strategy != "shadow" {
+ if !cfg.Enabled || !cfg.StorePassEvents || cfg.Strategy != "priority" {
t.Fatalf("boolean/strategy env not applied: %#v", cfg)
}
if cfg.WorkerCount != 9 || cfg.QueueCapacity != 12345 {
diff --git a/ai-gateway/internal/service/promptaudit/diagnostics_test.go b/ai-gateway/internal/service/promptaudit/diagnostics_test.go
index bccee339b8f580fa3a15ba59d8dde8062f080d10..078c01f03d2a71e8ebb861a65cab666211eb1d62 100644
--- a/ai-gateway/internal/service/promptaudit/diagnostics_test.go
+++ b/ai-gateway/internal/service/promptaudit/diagnostics_test.go
@@ -146,7 +146,7 @@ func TestPromptAuditSmallHelpersExposeOperatorSafeDefaults(t *testing.T) {
if promptAuditProbeErrorMessage(nil) != "" {
t.Fatal("nil probe error message should stay empty")
}
- if got := promptAuditProbeErrorMessage(newLLMGuardError("openai_guard_ready_failed", "", true, 503)); !strings.Contains(got, "openai_guard_ready_failed") || !strings.Contains(got, "status=503") {
- t.Fatalf("probe error message should expose stable code and status when message is empty, got %q", got)
+ if got := promptAuditProbeErrorMessage(newLLMGuardError("openai_guard_ready_failed", "", true, 503)); got != "Guard 探测失败" {
+ t.Fatalf("probe error message should remain generic and not expose upstream detail, got %q", got)
}
}
diff --git a/ai-gateway/internal/service/promptaudit/enqueue.go b/ai-gateway/internal/service/promptaudit/enqueue.go
index 6ac5329666fa875ac1984d7402f28afd4a72de3c..df92920a9763c3afff208d6efedc16a872764d0c 100644
--- a/ai-gateway/internal/service/promptaudit/enqueue.go
+++ b/ai-gateway/internal/service/promptaudit/enqueue.go
@@ -15,7 +15,10 @@ import (
"github.com/mt21625457/aicodex/internal/types"
)
-const promptAuditEnqueueAttemptedKey = "prompt_audit_enqueue_attempted"
+const (
+ promptAuditEnqueueAttemptedKey = "prompt_audit_enqueue_attempted"
+ promptAuditConfigCacheTTL = 5 * time.Second
+)
var (
defaultMemoryPayloadStore = NewMemoryPayloadStore()
@@ -24,8 +27,13 @@ var (
configCacheMu sync.Mutex
configCacheValue Config
configCacheLoadedAt time.Time
+ configCacheRefreshAfter time.Time
+ configCacheLastError string
+ configCacheLastErrorAt time.Time
)
+const promptGuardConfigInvalidationChannel = "aicodex:prompt_guard:config:invalidate"
+
func SetDefaultsForTesting(repo JobRepository, payloadStore PayloadStore) func() {
previousRepo := defaultRepository
previousPayload := defaultPayloadStore
@@ -48,10 +56,130 @@ func ConfigureDefaultPayloadStore(payloadStore PayloadStore) {
func ClearConfigCache() {
configCacheMu.Lock()
configCacheLoadedAt = time.Time{}
+ configCacheRefreshAfter = time.Time{}
configCacheValue = Config{}
+ configCacheLastError = ""
+ configCacheLastErrorAt = time.Time{}
configCacheMu.Unlock()
}
+func invalidateConfigCache() {
+ configCacheMu.Lock()
+ configCacheRefreshAfter = time.Time{}
+ configCacheMu.Unlock()
+}
+
+func installConfigSnapshot(cfg Config) Config {
+ now := time.Now()
+ configCacheMu.Lock()
+ if configCacheValue.ConfigVersion > 0 && normalizeConfigVersion(cfg.ConfigVersion) < normalizeConfigVersion(configCacheValue.ConfigVersion) {
+ current := configCacheValue
+ configCacheMu.Unlock()
+ return current
+ }
+ configCacheValue = cfg
+ configCacheLoadedAt = now
+ configCacheRefreshAfter = now.Add(promptAuditConfigCacheTTL)
+ configCacheLastError = ""
+ configCacheLastErrorAt = time.Time{}
+ configCacheMu.Unlock()
+ LogInfoEvent(
+ "prompt_guard.config_loaded",
+ Field("status", "success"),
+ Field("config_version", normalizeConfigVersion(cfg.ConfigVersion)),
+ Field("blocking_enabled", cfg.Enabled && cfg.BlockingEnabled),
+ )
+ return cfg
+}
+
+func publishConfigInvalidation(ctx context.Context, configVersion int64) {
+ if !common.RedisEnabled || common.RDB == nil {
+ LogWarnEvent(
+ "prompt_guard.config_reload_degraded",
+ Field("status", "degraded"),
+ Field("error_code", "redis_unavailable"),
+ Field("config_version", normalizeConfigVersion(configVersion)),
+ )
+ return
+ }
+ if err := common.RDB.Publish(ctx, promptGuardConfigInvalidationChannel, fmt.Sprintf("%d", normalizeConfigVersion(configVersion))).Err(); err != nil {
+ LogWarnEvent(
+ "prompt_guard.config_reload_degraded",
+ Field("status", "degraded"),
+ Field("error_code", "config_invalidation_publish_failed"),
+ Field("config_version", normalizeConfigVersion(configVersion)),
+ Field("error_kind", "redis_publish_failed"),
+ )
+ }
+}
+
+// StartConfigInvalidationSubscriber 监听多实例配置失效通知;Redis 不可用时继续使用 5 秒 TTL。
+func StartConfigInvalidationSubscriber(ctx context.Context) {
+ if !common.RedisEnabled || common.RDB == nil {
+ return
+ }
+ go func() {
+ pubsub := common.RDB.Subscribe(ctx, promptGuardConfigInvalidationChannel)
+ defer pubsub.Close()
+ if _, err := pubsub.Receive(ctx); err != nil {
+ LogWarnEvent(
+ "prompt_guard.config_reload_degraded",
+ Field("status", "degraded"),
+ Field("error_code", "config_invalidation_subscribe_failed"),
+ Field("error_kind", "redis_subscribe_failed"),
+ )
+ return
+ }
+ channel := pubsub.Channel()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case _, ok := <-channel:
+ if !ok {
+ LogWarnEvent(
+ "prompt_guard.config_reload_degraded",
+ Field("status", "degraded"),
+ Field("error_code", "config_invalidation_channel_closed"),
+ Field("error_kind", "redis_subscription_closed"),
+ )
+ return
+ }
+ invalidateConfigCache()
+ if cfg, err := NewConfigService(nil).Load(ctx); err == nil {
+ installConfigSnapshot(cfg)
+ } else {
+ recordConfigLoadError(err)
+ }
+ }
+ }
+ }()
+}
+
+func recordConfigLoadError(err error) {
+ if err == nil {
+ return
+ }
+ configCacheMu.Lock()
+ configCacheLastError = "提示词审计配置加载失败"
+ configCacheLastErrorAt = time.Now().UTC()
+ lastVersion := configCacheValue.ConfigVersion
+ configCacheMu.Unlock()
+ LogWarnEvent(
+ "prompt_guard.config_reload_degraded",
+ Field("status", "degraded"),
+ Field("error_code", "config_load_failed"),
+ Field("config_version", normalizeConfigVersion(lastVersion)),
+ Field("error_kind", "config_load_failed"),
+ )
+}
+
+func configLoadRuntimeState() (Config, time.Time, string, time.Time) {
+ configCacheMu.Lock()
+ defer configCacheMu.Unlock()
+ return configCacheValue, configCacheLoadedAt, configCacheLastError, configCacheLastErrorAt
+}
+
func MaybeEnqueueFromGateway(c *gin.Context, relayFormat types.RelayFormat, requestBody []byte) {
maybeEnqueueFromGateway(c, relayFormat, requestBody, false)
}
@@ -121,13 +249,16 @@ func EnqueueFromBody(ctx context.Context, relayFormat types.RelayFormat, path st
Field("reason", "config_load_failed"),
Field("request_id", snapshotContext.RequestID),
Field("error_code", "config_load_failed"),
- Field("error_kind", err.Error()),
+ Field("error_kind", "config_load_failed"),
)
return false, err
}
if !cfg.Enabled {
return false, nil
}
+ if cfg.BlockingEnabled {
+ return false, nil
+ }
if ok, reason := cfg.ShouldAuditGroup(snapshotContext.Group); !ok {
LogWarnEvent(
"prompt_audit.enqueue_dropped",
@@ -380,18 +511,31 @@ func hasTopLevelJSONKey(body []byte, key string) bool {
}
func loadCachedConfig(ctx context.Context) (Config, error) {
+ now := time.Now()
configCacheMu.Lock()
- defer configCacheMu.Unlock()
- if !configCacheLoadedAt.IsZero() && time.Since(configCacheLoadedAt) < 5*time.Second {
- return configCacheValue, nil
+ if configCacheValue.ConfigVersion > 0 && now.Before(configCacheRefreshAfter) {
+ cfg := configCacheValue
+ configCacheMu.Unlock()
+ return cfg, nil
}
+ lastValid := configCacheValue
+ hasLastValid := !configCacheLoadedAt.IsZero() || lastValid.ConfigVersion > 0
+ configCacheMu.Unlock()
cfg, err := NewConfigService(nil).Load(ctx)
if err != nil {
+ recordConfigLoadError(err)
+ if hasLastValid {
+ configCacheMu.Lock()
+ if configCacheValue.ConfigVersion == lastValid.ConfigVersion {
+ configCacheRefreshAfter = now.Add(promptAuditConfigCacheTTL)
+ lastValid = configCacheValue
+ }
+ configCacheMu.Unlock()
+ return lastValid, nil
+ }
return Config{}, err
}
- configCacheValue = cfg
- configCacheLoadedAt = time.Now()
- return cfg, nil
+ return installConfigSnapshot(cfg), nil
}
func enqueueError(code string, format string, args ...any) error {
diff --git a/ai-gateway/internal/service/promptaudit/openai_client.go b/ai-gateway/internal/service/promptaudit/openai_client.go
index c429064815c47c69a88b769828ef1046343bddfc..aa3f94f029dd54b12e33d608c6234136d993dcdf 100644
--- a/ai-gateway/internal/service/promptaudit/openai_client.go
+++ b/ai-gateway/internal/service/promptaudit/openai_client.go
@@ -19,7 +19,7 @@ type OpenAICompatibleClient struct {
func NewOpenAICompatibleClient(httpClient *http.Client) *OpenAICompatibleClient {
if httpClient == nil {
- httpClient = &http.Client{}
+ httpClient = newSecureGuardHTTPClient()
}
return &OpenAICompatibleClient{httpClient: httpClient}
}
@@ -90,6 +90,9 @@ func (c *OpenAICompatibleClient) ScanPrompt(ctx context.Context, endpoint Endpoi
Field("latency_ms", result.LatencyMS),
)
LogInfoEvent("prompt_audit.scan_chunk_completed", fields...)
+ if scanCtx.StopOnBlock && result.Action == "Block" {
+ break
+ }
}
prependAggregatedGuardPolicy(&aggregated, len(chunks), inputChars, inputLimit)
LogInfoEvent(
@@ -118,6 +121,9 @@ func (c *OpenAICompatibleClient) scanPromptChunk(ctx context.Context, endpoint E
if chatURL == "" {
return LLMGuardScanResult{}, newLLMGuardError("openai_guard_not_configured", "OpenAI 兼容审计 Base URL 为空", false, 0)
}
+ if err := validateGuardBaseURL(firstNonEmptyString(endpoint.BaseURL, endpoint.ScanURL)); err != nil {
+ return LLMGuardScanResult{}, newLLMGuardError("openai_guard_endpoint_denied", "Guard Base URL 未通过安全校验", false, 0)
+ }
model := normalizeGuardModel(ProtocolOpenAICompatible, endpoint.Model)
timeout := time.Duration(endpoint.TimeoutMS) * time.Millisecond
if timeout <= 0 {
@@ -141,7 +147,7 @@ func (c *OpenAICompatibleClient) scanPromptChunk(ctx context.Context, endpoint E
}
req, err := http.NewRequestWithContext(callCtx, http.MethodPost, chatURL, bytes.NewReader(body))
if err != nil {
- return LLMGuardScanResult{}, err
+ return LLMGuardScanResult{}, newLLMGuardError("openai_guard_request_invalid", "创建 Guard 请求失败", false, 0)
}
req.Header.Set("Content-Type", "application/json")
if token := strings.TrimSpace(endpoint.Token); token != "" {
@@ -155,7 +161,7 @@ func (c *OpenAICompatibleClient) scanPromptChunk(ctx context.Context, endpoint E
if errors.Is(callCtx.Err(), context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout") {
return LLMGuardScanResult{}, newLLMGuardError("openai_guard_timeout", "OpenAI 兼容审计调用超时", true, 0)
}
- return LLMGuardScanResult{}, newLLMGuardError("openai_guard_request_failed", err.Error(), true, 0)
+ return LLMGuardScanResult{}, newLLMGuardError("openai_guard_request_failed", "OpenAI 兼容审计请求失败", true, 0)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
@@ -169,8 +175,15 @@ func (c *OpenAICompatibleClient) scanPromptChunk(ctx context.Context, endpoint E
return LLMGuardScanResult{}, newLLMGuardError(code, message, retryable, resp.StatusCode)
}
+ responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxGuardResponseBytes+1))
+ if err != nil {
+ return LLMGuardScanResult{}, newLLMGuardError("openai_guard_invalid_response", "读取 Guard 响应失败", false, resp.StatusCode)
+ }
+ if int64(len(responseBody)) > maxGuardResponseBytes {
+ return LLMGuardScanResult{}, newLLMGuardError("openai_guard_invalid_response", "Guard 响应超过大小上限", false, resp.StatusCode)
+ }
var payload map[string]any
- if err := json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&payload); err != nil {
+ if err := json.Unmarshal(responseBody, &payload); err != nil {
return LLMGuardScanResult{}, newLLMGuardError("openai_guard_invalid_response", err.Error(), false, resp.StatusCode)
}
content := extractOpenAIChatContent(payload)
@@ -183,6 +196,9 @@ func (c *OpenAICompatibleClient) scanPromptChunk(ctx context.Context, endpoint E
if len(scanners) == 0 {
enabledCategories = parsed.Categories
}
+ if parsed.Safety == SafetyUnsafe && parsed.HasUnknownCategory {
+ enabledCategories = append(enabledCategories, "unknown_unsafe")
+ }
result := buildQwen3GuardScanResult(parsed, enabledCategories, model, latencyMS)
return result, nil
}
@@ -324,6 +340,9 @@ func (c *OpenAICompatibleClient) CheckReady(ctx context.Context, endpoint Endpoi
if base == "" {
return newLLMGuardError("openai_guard_not_configured", "OpenAI 兼容审计 Base URL 为空", false, 0)
}
+ if err := validateGuardBaseURL(base); err != nil {
+ return newLLMGuardError("openai_guard_endpoint_denied", "Guard Base URL 未通过安全校验", false, 0)
+ }
if err := c.checkModels(ctx, endpoint, base); err == nil {
return nil
} else if !shouldFallbackOpenAIReadyCheck(err) {
@@ -342,11 +361,11 @@ func (c *OpenAICompatibleClient) checkModels(ctx context.Context, endpoint Endpo
defer cancel()
modelsURL, err := url.JoinPath(base, "/v1/models")
if err != nil {
- return err
+ return newLLMGuardError("openai_guard_request_invalid", "创建 Guard 探测地址失败", false, 0)
}
req, err := http.NewRequestWithContext(callCtx, http.MethodGet, modelsURL, nil)
if err != nil {
- return err
+ return newLLMGuardError("openai_guard_request_invalid", "创建 Guard 探测请求失败", false, 0)
}
if token := strings.TrimSpace(endpoint.Token); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
@@ -356,7 +375,7 @@ func (c *OpenAICompatibleClient) checkModels(ctx context.Context, endpoint Endpo
if errors.Is(callCtx.Err(), context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout") {
return newLLMGuardError("openai_guard_timeout", "OpenAI 兼容 /v1/models 超时", true, 0)
}
- return newLLMGuardError("openai_guard_request_failed", err.Error(), true, 0)
+ return newLLMGuardError("openai_guard_request_failed", "OpenAI 兼容 Guard 探测请求失败", true, 0)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
@@ -384,7 +403,11 @@ func buildQwen3GuardScanResult(parsed qwen3GuardParsed, categories []string, mod
isValid := true
switch safety {
case SafetyUnsafe:
- action = "Block"
+ if len(categories) > 0 || len(parsed.Categories) == 0 || parsed.HasUnknownCategory {
+ action = "Block"
+ } else {
+ action = "Warn"
+ }
isValid = false
case SafetyControversial:
action = "Warn"
@@ -415,14 +438,26 @@ func buildQwen3GuardScanResult(parsed qwen3GuardParsed, categories []string, mod
if len(findings) > 0 {
evidence["_guard_findings"] = findings
}
+ observedFindings := make([]ScannerEvidenceItem, 0, len(parsed.Categories))
+ for _, category := range parsed.Categories {
+ observedFindings = append(observedFindings, ScannerEvidenceItem{
+ ScannerID: category,
+ Category: category,
+ Kind: "classification",
+ Severity: safety,
+ })
+ }
+ if len(observedFindings) > 0 {
+ evidence["_guard_observed_categories"] = observedFindings
+ }
evidence["_guard_policy"] = []ScannerEvidenceItem{{
Kind: "policy",
Summary: fmt.Sprintf("action=%s safety=%s model=%s", action, safety, model),
Metadata: map[string]any{
- "safety": safety,
- "categories": categories,
- "model": model,
- "raw": trimForDB(parsed.Raw, 512),
+ "safety": safety,
+ "observed_categories": append([]string(nil), parsed.Categories...),
+ "enforced_categories": append([]string(nil), categories...),
+ "model": model,
},
}}
diff --git a/ai-gateway/internal/service/promptaudit/probe.go b/ai-gateway/internal/service/promptaudit/probe.go
index 95d6e8db20a2cb194a0e35f701d1548a1d52709b..d7439473508570da5bdd0c049eb2e8737df5c82e 100644
--- a/ai-gateway/internal/service/promptaudit/probe.go
+++ b/ai-gateway/internal/service/promptaudit/probe.go
@@ -82,13 +82,24 @@ func promptAuditProbeErrorCode(err error) string {
}
func promptAuditProbeErrorMessage(err error) string {
- if llmErr := llmGuardErrorFrom(err); llmErr != nil && strings.TrimSpace(llmErr.Message) != "" {
- return llmErr.Message
+ if llmErr := llmGuardErrorFrom(err); llmErr != nil {
+ switch llmErr.Code {
+ case "openai_guard_auth_failed":
+ return "Guard 认证失败"
+ case "openai_guard_timeout":
+ return "Guard 探测超时"
+ case "openai_guard_invalid_response":
+ return "Guard 返回了非法响应"
+ case "openai_guard_endpoint_denied":
+ return "Guard 地址未通过安全校验"
+ default:
+ return "Guard 探测失败"
+ }
}
if err == nil {
return ""
}
- return err.Error()
+ return "Guard 探测失败"
}
func llmGuardErrorFrom(err error) *LLMGuardError {
diff --git a/ai-gateway/internal/service/promptaudit/probe_test.go b/ai-gateway/internal/service/promptaudit/probe_test.go
index a529981f7f9108b8a86ee4aa1d67e90f8fb54786..4eae84d8c78c7c2a39987e5ecdcb456d0358e23e 100644
--- a/ai-gateway/internal/service/promptaudit/probe_test.go
+++ b/ai-gateway/internal/service/promptaudit/probe_test.go
@@ -100,7 +100,7 @@ func TestProbeEndpointReturnsStableGuardErrorFields(t *testing.T) {
TimeoutMS: 1000,
})
- if result.OK || result.Status != "error" || result.ErrorCode != "openai_guard_auth_failed" || result.Message != "bad api key" {
+ if result.OK || result.Status != "error" || result.ErrorCode != "openai_guard_auth_failed" || result.Message != "Guard 认证失败" {
t.Fatalf("unexpected probe result: %#v", result)
}
if result.HTTPStatus != http.StatusUnauthorized || result.Retryable {
@@ -118,7 +118,7 @@ func TestProbeEndpointReturnsGenericProbeFailureForUnknownError(t *testing.T) {
TimeoutMS: 1000,
})
- if result.OK || result.ErrorCode != "prompt_audit_probe_failed" || result.Message != "network unavailable" {
+ if result.OK || result.ErrorCode != "prompt_audit_probe_failed" || result.Message != "Guard 探测失败" {
t.Fatalf("unexpected generic probe failure: %#v", result)
}
}
diff --git a/ai-gateway/internal/service/promptaudit/qwen3guard.go b/ai-gateway/internal/service/promptaudit/qwen3guard.go
index d510b9afd47822f845de7205ed0a1b97fadfed2e..cc8dbc006642e2d95d84273f51f73ab69d885b84 100644
--- a/ai-gateway/internal/service/promptaudit/qwen3guard.go
+++ b/ai-gateway/internal/service/promptaudit/qwen3guard.go
@@ -5,11 +5,11 @@ import (
"strings"
)
- const (
- ProtocolOpenAICompatible = "openai_compatible"
-
- DefaultQwen3GuardModel = "sileader/qwen3guard:0.6b"
- ScannerBackendQwen3Guard = "qwen3guard-openai"
+const (
+ ProtocolOpenAICompatible = "openai_compatible"
+
+ DefaultQwen3GuardModel = "sileader/qwen3guard:0.6b"
+ ScannerBackendQwen3Guard = "qwen3guard-openai"
SafetySafe = "Safe"
SafetyControversial = "Controversial"
@@ -42,25 +42,26 @@ func defaultOpenAICompatibleScanners() []string {
return Qwen3GuardCategoryCatalog()
}
- func normalizeProtocol(value string) string {
- // 提示词审计仅支持 OpenAI 兼容;历史 llm_guard / 空值一律归一。
- _ = value
- return ProtocolOpenAICompatible
- }
-
- func normalizeGuardModel(_ string, value string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return DefaultQwen3GuardModel
- }
- return value
+func normalizeProtocol(value string) string {
+ // 提示词审计仅支持 OpenAI 兼容;历史 llm_guard / 空值一律归一。
+ _ = value
+ return ProtocolOpenAICompatible
+}
+
+func normalizeGuardModel(_ string, value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return DefaultQwen3GuardModel
}
+ return value
+}
type qwen3GuardParsed struct {
- Safety string
- Categories []string
- Raw string
- Valid bool
+ Safety string
+ Categories []string
+ HasUnknownCategory bool
+ Raw string
+ Valid bool
}
func parseQwen3GuardOutput(content string) qwen3GuardParsed {
@@ -69,16 +70,46 @@ func parseQwen3GuardOutput(content string) qwen3GuardParsed {
if content == "" {
return parsed
}
- if match := safetyLineRegexp.FindStringSubmatch(content); len(match) == 2 {
- parsed.Safety = canonicalizeSafety(match[1])
+ nonEmptyLines := make([]string, 0, 2)
+ for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
+ if strings.TrimSpace(line) != "" {
+ nonEmptyLines = append(nonEmptyLines, line)
+ }
}
- if match := categoriesLineRegexp.FindStringSubmatch(content); len(match) == 2 {
- parsed.Categories = splitCategories(match[1])
+ if len(nonEmptyLines) != 2 {
+ return parsed
}
- parsed.Valid = parsed.Safety != ""
+ safetyMatches := safetyLineRegexp.FindAllStringSubmatch(content, -1)
+ categoryMatches := categoriesLineRegexp.FindAllStringSubmatch(content, -1)
+ if len(safetyMatches) != 1 || len(categoryMatches) != 1 {
+ return parsed
+ }
+ switch strings.TrimSpace(safetyMatches[0][1]) {
+ case SafetySafe, SafetyControversial, SafetyUnsafe:
+ parsed.Safety = strings.TrimSpace(safetyMatches[0][1])
+ default:
+ return parsed
+ }
+ parsed.Categories = splitCategories(categoryMatches[0][1])
+ for _, category := range parsed.Categories {
+ if !isKnownQwen3GuardCategory(category) {
+ parsed.HasUnknownCategory = true
+ }
+ }
+ parsed.Valid = true
return parsed
}
+func isKnownQwen3GuardCategory(category string) bool {
+ key := normalizeScannerKey(category)
+ for _, known := range qwen3GuardCategoryCatalog {
+ if normalizeScannerKey(known) == key {
+ return true
+ }
+ }
+ return false
+}
+
func canonicalizeSafety(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "safe":
@@ -128,29 +159,29 @@ func canonicalizeQwen3GuardCategory(value string) string {
return item
}
}
- aliases := map[string]string{
- "violence": "Violent",
- "violentcontent": "Violent",
- "nonviolentillegalacts": "Non-violent Illegal Acts",
- "illegal": "Non-violent Illegal Acts",
- "sexual": "Sexual Content or Sexual Acts",
- "sexualcontent": "Sexual Content or Sexual Acts",
- "sexualcontentorsexualacts": "Sexual Content or Sexual Acts",
- "selfharm": "Suicide & Self-Harm",
- "suicide": "Suicide & Self-Harm",
- "suicideandselfharm": "Suicide & Self-Harm",
- "unethical": "Unethical Acts",
- "hate": "Unethical Acts",
- "political": "Politically Sensitive Topics",
- "politicallysensitive": "Politically Sensitive Topics",
- "politicallysensitivetopics": "Politically Sensitive Topics",
- "copyright": "Copyright Violation",
- "copyrightviolation": "Copyright Violation",
- "promptinjection": "Jailbreak",
- "llamapromptguard2": "Jailbreak",
- "injection": "Jailbreak",
- "secrets": "PII",
- }
+ aliases := map[string]string{
+ "violence": "Violent",
+ "violentcontent": "Violent",
+ "nonviolentillegalacts": "Non-violent Illegal Acts",
+ "illegal": "Non-violent Illegal Acts",
+ "sexual": "Sexual Content or Sexual Acts",
+ "sexualcontent": "Sexual Content or Sexual Acts",
+ "sexualcontentorsexualacts": "Sexual Content or Sexual Acts",
+ "selfharm": "Suicide & Self-Harm",
+ "suicide": "Suicide & Self-Harm",
+ "suicideandselfharm": "Suicide & Self-Harm",
+ "unethical": "Unethical Acts",
+ "hate": "Unethical Acts",
+ "political": "Politically Sensitive Topics",
+ "politicallysensitive": "Politically Sensitive Topics",
+ "politicallysensitivetopics": "Politically Sensitive Topics",
+ "copyright": "Copyright Violation",
+ "copyrightviolation": "Copyright Violation",
+ "promptinjection": "Jailbreak",
+ "llamapromptguard2": "Jailbreak",
+ "injection": "Jailbreak",
+ "secrets": "PII",
+ }
if mapped, ok := aliases[key]; ok {
return mapped
}
diff --git a/ai-gateway/internal/service/promptaudit/runtime.go b/ai-gateway/internal/service/promptaudit/runtime.go
index befb3463c092e42230530574ff38f4e5f04b1854..a3f9959d3c2ba17e8a0adb71a83139de6f2b57a8 100644
--- a/ai-gateway/internal/service/promptaudit/runtime.go
+++ b/ai-gateway/internal/service/promptaudit/runtime.go
@@ -33,21 +33,46 @@ func Runtime(ctx context.Context, repo JobRepository, configSvc *ConfigService)
if configSvc == nil {
configSvc = NewConfigService(nil)
}
- cfg, err := configSvc.Public(ctx)
- if err != nil {
- cfg = DefaultConfig().Public()
+ cfg, configLoadErr := configSvc.Public(ctx)
+ if configLoadErr != nil {
+ recordConfigLoadError(configLoadErr)
+ activeCfg, _, _, _ := configLoadRuntimeState()
+ if activeCfg.ConfigVersion > 0 {
+ cfg = activeCfg.Public()
+ } else {
+ cfg = DefaultConfig().Public()
+ }
}
snapshot := RuntimeSnapshot{
- Enabled: cfg.Enabled,
- ProcessStatus: "not_started",
- QueueCapacity: cfg.QueueCapacity,
- WorkerTotal: cfg.WorkerCount,
- LLMGuardConnectivity: publicConfigConnectivity(cfg),
- StorageSupported: repo.StorageSupported(),
- Config: cfg,
- QueueBackend: queueBackendName(repo),
- PayloadStore: payloadStoreName(defaultPayloadStore),
- PayloadStoreDegraded: payloadStoreDegraded(defaultPayloadStore),
+ Enabled: cfg.Enabled,
+ BlockingEnabled: cfg.Enabled && cfg.BlockingEnabled,
+ EffectiveMode: effectivePromptAuditMode(cfg.Enabled, cfg.BlockingEnabled),
+ ExpectedConfigVersion: normalizeConfigVersion(cfg.ConfigVersion),
+ ProcessStatus: "not_started",
+ QueueCapacity: cfg.QueueCapacity,
+ WorkerTotal: cfg.WorkerCount,
+ LLMGuardConnectivity: publicConfigConnectivity(cfg),
+ StorageSupported: repo.StorageSupported(),
+ Config: cfg,
+ PromptGuardMetrics: GetPromptGuardMetricsSnapshot(),
+ QueueBackend: queueBackendName(repo),
+ PayloadStore: payloadStoreName(defaultPayloadStore),
+ PayloadStoreDegraded: payloadStoreDegraded(defaultPayloadStore),
+ }
+ activeCfg, loadedAt, loadErr, loadErrAt := configLoadRuntimeState()
+ if activeCfg.ConfigVersion > 0 {
+ snapshot.ActiveConfigVersion = normalizeConfigVersion(activeCfg.ConfigVersion)
+ }
+ if !loadedAt.IsZero() {
+ loadedAt = loadedAt.UTC()
+ snapshot.ConfigLoadedAt = &loadedAt
+ }
+ if loadErr != "" {
+ snapshot.ConfigLoadError = loadErr
+ if !loadErrAt.IsZero() {
+ loadErrAt = loadErrAt.UTC()
+ snapshot.ConfigLoadErrorAt = &loadErrAt
+ }
}
if repo.StorageSupported() {
if stats, err := repo.RuntimeDBStats(ctx); err == nil {
@@ -93,13 +118,21 @@ func Runtime(ctx context.Context, repo JobRepository, configSvc *ConfigService)
}
if cfg.Enabled && !snapshot.StorageSupported {
snapshot.ProcessStatus = "error"
- snapshot.LastErrorCode = "storage_not_supported"
snapshot.LastErrorMessage = "提示词审计日志 Ent 客户端未初始化"
+ if cfg.BlockingEnabled {
+ snapshot.ProcessStatus = "degraded"
+ snapshot.LastErrorMessage += ";同步判定仍可执行,但结果记录降级"
+ }
+ snapshot.LastErrorCode = "storage_not_supported"
}
if cfg.Enabled && (defaultPayloadStore == nil || !defaultPayloadStore.Available()) {
snapshot.ProcessStatus = "error"
- snapshot.LastErrorCode = "payload_store_unavailable"
snapshot.LastErrorMessage = ErrPayloadStoreUnavailable.Error()
+ if cfg.BlockingEnabled {
+ snapshot.ProcessStatus = "degraded"
+ snapshot.LastErrorMessage += ";同步判定不依赖异步载荷存储"
+ }
+ snapshot.LastErrorCode = "payload_store_unavailable"
}
if cfg.Enabled && snapshot.PayloadStoreDegraded && snapshot.ProcessStatus != "error" {
snapshot.ProcessStatus = "degraded"
@@ -108,9 +141,28 @@ func Runtime(ctx context.Context, repo JobRepository, configSvc *ConfigService)
snapshot.LastErrorMessage = "提示词审计正在使用内存 payload store,仅适合单进程开发或测试"
}
}
+ if configLoadErr != nil {
+ if snapshot.ProcessStatus != "error" {
+ snapshot.ProcessStatus = "degraded"
+ }
+ if snapshot.LastErrorCode == "" {
+ snapshot.LastErrorCode = "config_load_failed"
+ snapshot.LastErrorMessage = "提示词审计配置加载失败"
+ }
+ }
return snapshot
}
+func effectivePromptAuditMode(enabled bool, blockingEnabled bool) string {
+ if !enabled {
+ return "off"
+ }
+ if blockingEnabled {
+ return "blocking"
+ }
+ return "async_audit"
+}
+
func (r *Runner) heartbeatLoop(ctx context.Context, cfg Config) {
defer r.wg.Done()
ticker := time.NewTicker(10 * time.Second)
diff --git a/ai-gateway/internal/service/promptaudit/runtime_coverage_test.go b/ai-gateway/internal/service/promptaudit/runtime_coverage_test.go
index ebbf045b144ca3c212fbfc19d3606c729264b9e2..6fe7669763d4110c1fa3b1028ea67dffb5d507bb 100644
--- a/ai-gateway/internal/service/promptaudit/runtime_coverage_test.go
+++ b/ai-gateway/internal/service/promptaudit/runtime_coverage_test.go
@@ -86,6 +86,8 @@ func TestRuntimeMergesDBStatsAndHeartbeatWithObservablePriority(t *testing.T) {
func TestRuntimeFallsBackToSafeConfigWhenConfigPublicLoadFails(t *testing.T) {
clearRuntimeHeartbeatForTesting()
+ ClearConfigCache()
+ t.Cleanup(ClearConfigCache)
repo := newFakePromptAuditRepo()
svc := NewConfigService(&configurableOptionStore{
values: map[string]string{},
@@ -100,8 +102,14 @@ func TestRuntimeFallsBackToSafeConfigWhenConfigPublicLoadFails(t *testing.T) {
if snapshot.WorkerTotal != 4 || snapshot.QueueCapacity != 10000 {
t.Fatalf("runtime safe defaults mismatch: worker=%d capacity=%d", snapshot.WorkerTotal, snapshot.QueueCapacity)
}
- if snapshot.ProcessStatus == "error" || snapshot.LastErrorCode != "" {
- t.Fatalf("config read failure should not fabricate runner error state, got status=%s code=%s", snapshot.ProcessStatus, snapshot.LastErrorCode)
+ if snapshot.ProcessStatus != "degraded" || snapshot.LastErrorCode != "config_load_failed" {
+ t.Fatalf("配置读取失败应明确标记运行态降级,got status=%s code=%s", snapshot.ProcessStatus, snapshot.LastErrorCode)
+ }
+ if snapshot.ConfigLoadError != "提示词审计配置加载失败" || snapshot.ConfigLoadErrorAt == nil {
+ t.Fatalf("配置读取失败应只暴露通用脱敏错误,got error=%q at=%v", snapshot.ConfigLoadError, snapshot.ConfigLoadErrorAt)
+ }
+ if strings.Contains(snapshot.ConfigLoadError, "option storage unavailable") || strings.Contains(snapshot.LastErrorMessage, "option storage unavailable") {
+ t.Fatalf("运行态不得泄露底层配置存储错误: %+v", snapshot)
}
}
diff --git a/ai-gateway/internal/service/promptaudit/types.go b/ai-gateway/internal/service/promptaudit/types.go
index c6d9f4f0ccd1257ee94614156a597fc7870a5545..ca5254997fafff386e044dfebb1e9e7dea0f1093 100644
--- a/ai-gateway/internal/service/promptaudit/types.go
+++ b/ai-gateway/internal/service/promptaudit/types.go
@@ -53,16 +53,17 @@ type PayloadStore interface {
}
type ScanPromptContext struct {
- JobID int64
- RequestID string
- UserID int
- TokenID int
- ChannelID int
- Endpoint string
- Protocol string
- Model string
- Group string
- Lease *ScanPromptLease
+ JobID int64
+ RequestID string
+ UserID int
+ TokenID int
+ ChannelID int
+ Endpoint string
+ Protocol string
+ Model string
+ Group string
+ StopOnBlock bool
+ Lease *ScanPromptLease
}
// ScanPromptLease 允许长文本分片扫描在每片开始前刷新 processing 租约,
@@ -370,28 +371,36 @@ type RuntimeDBStats struct {
}
type RuntimeSnapshot struct {
- Enabled bool `json:"enabled"`
- ProcessStatus string `json:"process_status"`
- QueueLength int `json:"queue_length"`
- QueueCapacity int `json:"queue_capacity"`
- WorkerTotal int `json:"worker_total"`
- ActiveWorkers int64 `json:"active_workers"`
- Enqueued int64 `json:"enqueued"`
- Dropped int64 `json:"dropped"`
- ProcessedTotal int64 `json:"processed_total"`
- FailedTotal int64 `json:"failed_total"`
- LastErrorCode string `json:"last_error_code"`
- LastErrorMessage string `json:"last_error_message"`
- LLMGuardConnectivity string `json:"llm_guard_connectivity"`
- StorageSupported bool `json:"storage_supported"`
- QueueBackend string `json:"queue_backend"`
- PayloadStore string `json:"payload_store"`
- PayloadStoreDegraded bool `json:"payload_store_degraded"`
- QueuedRows int64 `json:"queued_rows"`
- ProcessingRows int64 `json:"processing_rows"`
- LastEnqueuedAt *time.Time `json:"last_enqueued_at"`
- LastProcessedAt *time.Time `json:"last_processed_at"`
- LastFailedAt *time.Time `json:"last_failed_at"`
- HeartbeatAt *time.Time `json:"heartbeat_at"`
- Config PublicConfig `json:"config"`
+ Enabled bool `json:"enabled"`
+ BlockingEnabled bool `json:"blocking_enabled"`
+ EffectiveMode string `json:"effective_mode"`
+ ExpectedConfigVersion int64 `json:"expected_config_version"`
+ ActiveConfigVersion int64 `json:"active_config_version"`
+ ConfigLoadedAt *time.Time `json:"config_loaded_at,omitempty"`
+ ConfigLoadError string `json:"config_load_error,omitempty"`
+ ConfigLoadErrorAt *time.Time `json:"config_load_error_at,omitempty"`
+ ProcessStatus string `json:"process_status"`
+ QueueLength int `json:"queue_length"`
+ QueueCapacity int `json:"queue_capacity"`
+ WorkerTotal int `json:"worker_total"`
+ ActiveWorkers int64 `json:"active_workers"`
+ Enqueued int64 `json:"enqueued"`
+ Dropped int64 `json:"dropped"`
+ ProcessedTotal int64 `json:"processed_total"`
+ FailedTotal int64 `json:"failed_total"`
+ LastErrorCode string `json:"last_error_code"`
+ LastErrorMessage string `json:"last_error_message"`
+ LLMGuardConnectivity string `json:"llm_guard_connectivity"`
+ StorageSupported bool `json:"storage_supported"`
+ QueueBackend string `json:"queue_backend"`
+ PayloadStore string `json:"payload_store"`
+ PayloadStoreDegraded bool `json:"payload_store_degraded"`
+ QueuedRows int64 `json:"queued_rows"`
+ ProcessingRows int64 `json:"processing_rows"`
+ LastEnqueuedAt *time.Time `json:"last_enqueued_at"`
+ LastProcessedAt *time.Time `json:"last_processed_at"`
+ LastFailedAt *time.Time `json:"last_failed_at"`
+ HeartbeatAt *time.Time `json:"heartbeat_at"`
+ Config PublicConfig `json:"config"`
+ PromptGuardMetrics PromptGuardMetricsSnapshot `json:"prompt_guard_metrics"`
}
diff --git a/ai-gateway/internal/service/promptaudit/worker.go b/ai-gateway/internal/service/promptaudit/worker.go
index 0aec513f15a5120ba9ef83f1a5027a6ec87bbd02..f6e059106fe2af03ee44bb86cc891c0766267341 100644
--- a/ai-gateway/internal/service/promptaudit/worker.go
+++ b/ai-gateway/internal/service/promptaudit/worker.go
@@ -73,10 +73,14 @@ func (r *Runner) Start(ctx context.Context) error {
return ErrPayloadStoreUnavailable
}
workerCount := normalizeWorkerCount(cfg.WorkerCount)
+ installConfigSnapshot(cfg)
+ StartConfigInvalidationSubscriber(ctx)
LogInfoEvent(
"prompt_audit.started",
Field("status", "running"),
Field("enabled", cfg.Enabled),
+ Field("blocking_enabled", cfg.Enabled && cfg.BlockingEnabled),
+ Field("config_version", normalizeConfigVersion(cfg.ConfigVersion)),
Field("queue_capacity", cfg.QueueCapacity),
Field("worker_total", workerCount),
Field("endpoint_count", len(cfg.EnabledEndpoints())),
diff --git a/ai-gateway/internal/types/error.go b/ai-gateway/internal/types/error.go
index 501ee10d7660fb6f6d91baddd44624bbe32120ab..a6a9d55d966b5bb0c50efe8a02ebdef6bfd3252c 100644
--- a/ai-gateway/internal/types/error.go
+++ b/ai-gateway/internal/types/error.go
@@ -75,14 +75,17 @@ const (
ErrorCodeBadRequestBody ErrorCode = "bad_request_body"
// response error
- ErrorCodeReadResponseBodyFailed ErrorCode = "read_response_body_failed"
- ErrorCodeBadResponseStatusCode ErrorCode = "bad_response_status_code"
- ErrorCodeBadResponse ErrorCode = "bad_response"
- ErrorCodeBadResponseBody ErrorCode = "bad_response_body"
- ErrorCodeEmptyResponse ErrorCode = "empty_response"
- ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error"
- ErrorCodeModelNotFound ErrorCode = "model_not_found"
- ErrorCodePromptBlocked ErrorCode = "prompt_blocked"
+ ErrorCodeReadResponseBodyFailed ErrorCode = "read_response_body_failed"
+ ErrorCodeBadResponseStatusCode ErrorCode = "bad_response_status_code"
+ ErrorCodeBadResponse ErrorCode = "bad_response"
+ ErrorCodeBadResponseBody ErrorCode = "bad_response_body"
+ ErrorCodeEmptyResponse ErrorCode = "empty_response"
+ ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error"
+ ErrorCodeModelNotFound ErrorCode = "model_not_found"
+ ErrorCodePromptBlocked ErrorCode = "prompt_blocked"
+ ErrorCodePromptGuardBlocked ErrorCode = "prompt_guard_blocked"
+ ErrorCodePromptGuardUnavailable ErrorCode = "prompt_guard_unavailable"
+ ErrorCodePromptGuardInvalidResponse ErrorCode = "prompt_guard_invalid_response"
// sql error
ErrorCodeQueryDataError ErrorCode = "query_data_error"
diff --git a/deploy/.env.example b/deploy/.env.example
index 01d5fb78a0550abcdcd9444088c38cc1404148bb..19bb22221b9e1520da78e73c94b7ea7e6ea4a705 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -306,22 +306,28 @@ AICODEX_REALTIME_WS_OUTBOUND_QUEUE_SIZE=64
# AICODEX_REALTIME_WS_ALLOWED_ORIGINS=https://console.example.com
# --- 用户输入提示词审计(默认关闭)---
-# 主 aicodex 进程内置审计 worker:模型请求只异步入队,worker 调用外部 LLM Guard HTTP API 扫描。
-# 开启前必须配置可用的 LLM Guard API,并确保 Redis 可用(完整提示词只以短 TTL 临时载荷写入 Redis,不落库)。
+# 主 aicodex 进程内置 Qwen3Guard 审计 worker;默认异步只审计,可显式开启同步阻止。
+# 开启前必须配置可用的 OpenAI 兼容 Guard;异步载荷使用 Redis 短 TTL,同步路径不持久化原文。
PROMPT_AUDIT_ENABLED=false
+# true=请求在渠道选择、计费和上游调用前同步等待 Guard;Block 或 Guard 不可用时 fail-closed。
+PROMPT_AUDIT_BLOCKING_ENABLED=false
# 是否持久化 pass 事件;默认 false,仅保存 flag / critical 等风险事件。
PROMPT_AUDIT_STORE_PASS_EVENTS=false
-# LLM Guard API 选择策略:priority / weighted / shadow
+# Guard 节点调度仅支持 priority(有序故障切换)。
PROMPT_AUDIT_STRATEGY=priority
# 主进程内审计 worker 数;建议按外部 LLM Guard API 实际吞吐灰度调大。
PROMPT_AUDIT_WORKER_COUNT=4
# 队列容量上限;达到上限时主请求继续转发,并输出 prompt_audit.enqueue_dropped。
PROMPT_AUDIT_QUEUE_CAPACITY=10000
-# 输入扫描器列表,逗号分隔。
-PROMPT_AUDIT_SCANNERS=PromptInjection,TokenLimit,Secrets,InvisibleText,Gibberish,Regex
-# Guard Prompt Scan URL,多个地址用逗号分隔;推荐直接填写完整审核路由。
-# 生产可使用外部审核服务,例如:https://scan.leagsoft.com/v1/scan/prompt
-LLM_GUARD_SCAN_URLS=https://scan.leagsoft.com/v1/scan/prompt
+# Qwen3Guard 输入类别,逗号分隔。
+PROMPT_AUDIT_SCANNERS=Violent,Non-violent Illegal Acts,Sexual Content or Sexual Acts,PII,Suicide & Self-Harm,Unethical Acts,Politically Sensitive Topics,Copyright Violation,Jailbreak
+# 推荐使用 OpenAI 兼容 Base URL、模型和 API Key;公网地址必须 HTTPS。
+PROMPT_AUDIT_BASE_URLS=
+PROMPT_AUDIT_MODEL=sileader/qwen3guard:0.6b
+PROMPT_AUDIT_API_KEYS=
+PROMPT_AUDIT_TIMEOUT_MS=30000
+# 旧变量兼容:按 OpenAI 兼容 Base URL 解释,多个地址用逗号分隔;新部署优先使用 PROMPT_AUDIT_BASE_URLS。
+LLM_GUARD_SCAN_URLS=
# 旧 LLM Guard API Base URL 兼容变量;为空时优先使用 LLM_GUARD_SCAN_URLS。
# 如果只填写服务根地址,AICodex 会兼容补齐 /v1/scan/prompt。
LLM_GUARD_API_BASE_URLS=
diff --git a/docs/constraints/41-ai-readable-logging.md b/docs/constraints/41-ai-readable-logging.md
index 7be9278ffd3ad7468fda6d21118ef387fc2b3075..7f2df768ab3e3d60de6ae7e0f3a0913fb66ab65f 100644
--- a/docs/constraints/41-ai-readable-logging.md
+++ b/docs/constraints/41-ai-readable-logging.md
@@ -592,3 +592,36 @@ App 下载中心以华为云 OBS/CDN updater metadata 为事实源时,必须
metadata 缓存事件必须用 `cache_key_hash` 表示缓存键,不得输出完整缓存键、完整 metadata URL、完整 package URL 或 metadata body。`ETag` 与 `Last-Modified` 只允许输出是否存在的布尔值,不得输出 header 原文。
禁止输出 Cookie、Authorization、完整带 query 的 URL、OBS 密钥、CDN 鉴权参数或安装包签名 URL 原文。日志中如需表示 metadata 或 package 来源,只能输出 host、平台键、文件名和允许范围内的稳定状态字段。
+
+## 13. Prompt Guard 同步门禁专项约束
+
+提示词同步阻止属于请求副作用边界,日志必须能直接回答“使用了哪个配置版本、为什么放行或拒绝、拒绝前是否触发渠道/计费/上游”。
+
+稳定事件名:
+
+- `prompt_guard.config_updated`
+- `prompt_guard.config_loaded`
+- `prompt_guard.config_reload_degraded`
+- `prompt_guard.evaluation_started`
+- `prompt_guard.allowed`
+- `prompt_guard.blocked`
+- `prompt_guard.failed`
+- `prompt_guard.result_record_failed`
+
+最小字段集合:
+
+- `request_id`、`user_id`、`token_id`、`group`
+- `protocol`、`endpoint`、`model`
+- `config_version`、`policy_id`、`policy_version`、`guard_endpoint_id`
+- `decision`、`action`、`chunk_total`、`latency_ms`
+- `status`、`error_code`、`stage`
+- `upstream_dispatched`、`billing_preconsumed`
+
+稳定错误码:
+
+- `prompt_guard_blocked`
+- `prompt_guard_unavailable`
+- `prompt_guard_invalid_response`
+- `prompt_guard_requires_audit_enabled`
+
+Block、Unavailable 和非法响应日志必须明确 `upstream_dispatched=false`、`billing_preconsumed=false`。禁止输出完整提示词、原始分片、API Key、Token、Authorization、完整 Guard URL、URL query、Guard 原始响应或内部优先分片边界。分类只允许输出归一化后的类别与稳定 scanner 名称。
diff --git a/docs/workflows/02-local-dev.md b/docs/workflows/02-local-dev.md
index 2ca1e966f237ae170aebbc15c5e461f2b05f3cde..683b5dcd389508b7e079702c7f5e614fa145db5f 100644
--- a/docs/workflows/02-local-dev.md
+++ b/docs/workflows/02-local-dev.md
@@ -25,21 +25,24 @@
### 提示词审计本地验证
-提示词审计由主 `aicodex` 进程异步投递任务,并在主进程内置 worker 中消费任务、调用外部 Guard Prompt Scan URL。完整原始提示词只会以短 TTL 临时载荷写入 Redis,数据库只保存 hash、脱敏预览、上下文和扫描结果。
+提示词审计支持两种执行模式:`blocking_enabled=false` 为异步只审计;`blocking_enabled=true` 为同步阻止。同步模式会在渠道选择、计费预扣和上游调用前调用 OpenAI 兼容 Qwen3Guard,命中 Block 返回 403Guard 不可用或输出非法返回 503。同步结果直接复用到脱敏事件,不重复调用 Guard。
-- 推荐在 `deploy/.env` 中设置新的完整审核 URL 和 API Key
+- 推荐在控制台保存配置;也可在 `deploy/.env` 中设置 OpenAI 兼容 Guard
- `PROMPT_AUDIT_ENABLED=true`
- - `LLM_GUARD_SCAN_URLS=https://scan.leagsoft.com/v1/scan/prompt`
- - `LLM_GUARD_API_TOKENS=sk-lg_xxx`,需替换为本地私有 API Key;如果外接多个 Guard API,可用逗号分隔,且不得提交真实 key。
+ - `PROMPT_AUDIT_BLOCKING_ENABLED=false`(先以异步模式建立基线,灰度时再开启)
+ - `PROMPT_AUDIT_BASE_URLS=https://guard.example.com/v1`
+ - `PROMPT_AUDIT_MODEL=sileader/qwen3guard:0.6b`
+ - `PROMPT_AUDIT_API_KEYS=sk_xxx`,只写入本地私有配置,不得提交真实 key。
- 旧 `laiyer/llm-guard-api` sidecar 仅作为迁移兼容样例保留:
- 随主栈 profile 启动:`cd deploy && docker compose -p aicodex --profile prompt-audit-llm-guard up -d llm-guard-api`
- 使用旧 sidecar 时可设置 `LLM_GUARD_SCAN_URLS=http://127.0.0.1:8000/v1/scan/prompt`;如果仍填写旧 `LLM_GUARD_API_BASE_URLS=http://127.0.0.1:8000`AICodex 会兼容补齐 `/v1/scan/prompt`。
- 启动主服务:`cd deploy && docker compose -p aicodex up -d aicodex`
-- 查看主服务审计日志:`cd deploy && docker compose -p aicodex logs -f aicodex | grep prompt_audit`
-- 连通性验证:登录控制台打开“HTTP 审计 → 提示词审计”后点击 endpoint 探测,或调用 `POST /api/prompt-audit/endpoints/probe`;后端会优先检查审核 URL 所属 origin 的 `/health`,必要时用安全探针调用 `scan_url`。
-- 运行态验证:调用 `GET /api/prompt-audit/runtime`,确认 `enabled=true`、`process_status=running`、`payload_store=redis`、`payload_store_degraded=false`、`llm_guard_connectivity=ok`。常见稳定错误码包括 `llm_guard_auth_failed`、`llm_guard_timeout`、`llm_guard_http_error`、`llm_guard_invalid_response`、`scan_payload_missing` 和 `payload_store_unavailable`。
+- 查看主服务审计日志:`cd deploy && docker compose -p aicodex logs -f aicodex | grep -E 'prompt_audit|prompt_guard'`
+- 连通性验证:登录控制台打开“提示词审计”后点击审计池探测,或调用 `POST /api/prompt-audit/endpoints/probe`。公网 Guard 只允许 HTTPSHTTP 仅允许 localhost、单标签内部服务名或显式私网 IP;重定向、link-local 和云元数据地址会被拒绝。
+- 运行态验证:调用 `GET /api/prompt-audit/runtime`,确认 `effective_mode`、`expected_config_version`、`active_config_version`、`config_loaded_at`、`process_status` 和 `llm_guard_connectivity` 符合预期。版本不一致或 `config_load_error` 非空时不得扩大灰度。
- 事件落库验证:发送包含 PromptInjection 特征的 `/v1/chat/completions`、`/v1/responses` 或 Claude Messages 请求,再查询 `GET /api/prompt-audit/events?decision=critical`;页面和接口只能展示脱敏预览、hash、scanner 命中和处理元数据。
-- 回滚方式:将 `PROMPT_AUDIT_ENABLED=false` 后重启主服务;提示词审计是异步旁路,关闭后不会影响主模型请求转发,已写入的审计事件可继续保留用于复核。
+- 同步验证:先用良性输入确认请求成功,再用 fake Guard 分别返回 `Safety: Unsafe / Categories: Jailbreak`、超时和非法格式,确认 HTTP 分别返回 403/503,且上游调用数、渠道重试数和预扣次数均为 0;Responses WebSocket 首轮和后续 `response.create` 也必须在本轮预扣前检查。
+- 回滚方式:在控制台关闭“同步阻止”并保存,即刻恢复异步只审计;无需关闭审计或删除历史事件。若需完全停用,再关闭“启用审计”。
## 前端
diff --git a/webui/src/api/promptAudit.test.ts b/webui/src/api/promptAudit.test.ts
index 2c1030573522eacc893b4f47c77ee26563ccbeb5..94f2a5f07a1d18bca2de73c7c22e49f49f65394c 100644
--- a/webui/src/api/promptAudit.test.ts
+++ b/webui/src/api/promptAudit.test.ts
@@ -76,6 +76,7 @@ const eventListParams: PromptAuditEventListParams = {
const savePayload: PromptAuditConfigSavePayload = {
enabled: true,
+ blocking_enabled: false,
store_pass_events: false,
strategy: 'priority',
worker_count: 16,
diff --git a/webui/src/features/prompt-audit/PromptAuditPage.test.tsx b/webui/src/features/prompt-audit/PromptAuditPage.test.tsx
index ede5204bd9a2d01541b09d9ab2468d3d98f7b4b4..243d722a91d77b2a8ec077825a804341cccd0e9d 100644
--- a/webui/src/features/prompt-audit/PromptAuditPage.test.tsx
+++ b/webui/src/features/prompt-audit/PromptAuditPage.test.tsx
@@ -80,6 +80,8 @@ const savePromptAuditConfigMock = vi.mocked(savePromptAuditConfig)
const configResponse: PromptAuditConfigResponse = {
enabled: true,
+ blocking_enabled: false,
+ config_version: 3,
store_pass_events: false,
strategy: 'priority',
worker_count: 16,
@@ -103,6 +105,10 @@ const configResponse: PromptAuditConfigResponse = {
const runtimeResponse: PromptAuditRuntime = {
enabled: true,
+ blocking_enabled: false,
+ effective_mode: 'async_audit',
+ expected_config_version: 3,
+ active_config_version: 3,
process_status: 'running',
queue_length: 0,
queue_capacity: 10000,
@@ -267,6 +273,7 @@ describe('PromptAuditPage', () => {
expect(savePromptAuditConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
+ blocking_enabled: false,
store_pass_events: false,
worker_count: 16,
queue_capacity: 10000,
@@ -290,6 +297,77 @@ describe('PromptAuditPage', () => {
expect(screen.getByLabelText('主审计池 API Key')).toHaveValue('')
})
+ it('同步阻止需要确认,保存后关闭审计会自动关闭阻止', async () => {
+ confirmImmediately()
+ const user = userEvent.setup()
+ renderWithRouter(<PromptAuditPage />)
+
+ const blockingSwitch = await screen.findByRole('switch', {
+ name: '同步阻止',
+ })
+ expect(blockingSwitch).not.toBeChecked()
+ await user.click(blockingSwitch)
+ expect(Modal.confirm).toHaveBeenCalledWith(
+ expect.objectContaining({ title: '确认开启同步阻止' }),
+ )
+ expect(blockingSwitch).toBeChecked()
+
+ await user.click(screen.getByRole('button', { name: /保存配置/ }))
+ await waitFor(() => {
+ expect(savePromptAuditConfigMock).toHaveBeenCalledWith(
+ expect.objectContaining({ blocking_enabled: true }),
+ )
+ })
+
+ await user.click(screen.getByRole('switch', { name: '启用审计' }))
+ expect(blockingSwitch).not.toBeChecked()
+ expect(blockingSwitch).toBeDisabled()
+ })
+
+ it('取消同步阻止风险确认时保持异步只审计草稿', async () => {
+ vi.spyOn(Modal, 'confirm').mockImplementation(() => undefined as never)
+ const user = userEvent.setup()
+ renderWithRouter(<PromptAuditPage />)
+
+ const blockingSwitch = await screen.findByRole('switch', {
+ name: '同步阻止',
+ })
+ await user.click(blockingSwitch)
+
+ expect(Modal.confirm).toHaveBeenCalledWith(
+ expect.objectContaining({ title: '确认开启同步阻止' }),
+ )
+ expect(blockingSwitch).not.toBeChecked()
+ expect(screen.getByText('异步只审计')).toBeInTheDocument()
+ expect(savePromptAuditConfigMock).not.toHaveBeenCalled()
+ })
+
+ it('未保存草稿不得冒充运行时审计状态', async () => {
+ const user = userEvent.setup()
+ renderWithRouter(<PromptAuditPage />)
+
+ expect(await screen.findByText('审计已启用')).toBeInTheDocument()
+ await user.click(screen.getByRole('switch', { name: '启用审计' }))
+
+ expect(screen.getByText('审计已启用')).toBeInTheDocument()
+ expect(screen.queryByText('审计未启用')).not.toBeInTheDocument()
+ expect(screen.getByText('有未保存更改')).toBeInTheDocument()
+ })
+
+ it('运行态配置版本不一致时明确展示降级提示和双版本', async () => {
+ fetchPromptAuditRuntimeMock.mockResolvedValueOnce({
+ ...runtimeResponse,
+ expected_config_version: 4,
+ active_config_version: 3,
+ config_load_error: 'config_load_failed',
+ })
+ renderWithRouter(<PromptAuditPage />)
+
+ expect(await screen.findByText('配置版本未同步')).toBeInTheDocument()
+ expect(screen.getByText('期望版本: 4')).toBeInTheDocument()
+ expect(screen.getByText('生效版本: 3')).toBeInTheDocument()
+ })
+
it('通过参数弹框修改权重、超时和单片输入上限后统一保存', async () => {
const user = userEvent.setup()
renderWithRouter(<PromptAuditPage />)
diff --git a/webui/src/features/prompt-audit/PromptAuditPage.tsx b/webui/src/features/prompt-audit/PromptAuditPage.tsx
index 2bc1aa5cd959a28dc79675344ba518132a49f104..03228aae0ff26f971e83cba86262d6202ae9259e 100644
--- a/webui/src/features/prompt-audit/PromptAuditPage.tsx
+++ b/webui/src/features/prompt-audit/PromptAuditPage.tsx
@@ -306,6 +306,31 @@ const PromptAuditPage = () => {
value: PromptAuditConfigState[K],
) => setConfig((current) => ({ ...current, [key]: value }))
+ const updateAuditEnabled = (enabled: boolean) => {
+ setConfig((current) => ({
+ ...current,
+ enabled,
+ blockingEnabled: enabled ? current.blockingEnabled : false,
+ }))
+ }
+
+ const updateBlockingEnabled = (enabled: boolean) => {
+ if (!enabled) {
+ updateConfig('blockingEnabled', false)
+ return
+ }
+ if (!config.enabled) return
+ Modal.confirm({
+ title: t('确认开启同步阻止'),
+ content: t(
+ '开启后,请求会在转发前等待 Guard 判定;命中 Block 或 Guard 不可用时不会访问上游,并将分别返回 403 或 503。',
+ ),
+ okText: t('确认开启'),
+ cancelText: t('取消'),
+ onOk: () => updateConfig('blockingEnabled', true),
+ })
+ }
+
const updateEndpoint = (
endpointID: string,
patch: Partial<PromptAuditEndpointState>,
@@ -383,7 +408,7 @@ const PromptAuditPage = () => {
const loadRuntime = useCallback(async () => {
const response = await fetchPromptAuditRuntime()
setRuntime(response)
- if (response.config) {
+ if (response.config && savedSnapshotRef.current === '') {
const normalized = normalizePromptAuditConfig(response.config)
setConfig({
...normalized,
@@ -512,6 +537,24 @@ const PromptAuditPage = () => {
config.auditGroupMode === 'all' || groupsLoading || groupsLoadFailed
const queueBacklog = Number(runtime?.queue_length || 0) > 0
+ const runtimeEffectiveMode =
+ runtime?.effective_mode ||
+ (runtime?.enabled
+ ? runtime?.blocking_enabled
+ ? 'blocking'
+ : 'async_audit'
+ : 'off')
+ const runtimeModeLabel =
+ runtimeEffectiveMode === 'blocking'
+ ? t('同步阻止')
+ : runtimeEffectiveMode === 'async_audit'
+ ? t('异步只审计')
+ : t('审计关闭')
+ const runtimeVersionMismatch =
+ Number(runtime?.expected_config_version || 0) > 0 &&
+ Number(runtime?.expected_config_version) !==
+ Number(runtime?.active_config_version || 0)
+ const runtimeAuditEnabled = runtime?.enabled ?? config.enabled
const saveConfig = useCallback(async () => {
if (saving) return
@@ -534,6 +577,7 @@ const PromptAuditPage = () => {
commitSavedSnapshot(buildConfigSnapshot(nextConfig))
}
Toast.success(t('提示词审计配置已保存'))
+ await loadRuntime()
} catch (error: unknown) {
const message = readPromptAuditErrorMessage(
error,
@@ -544,7 +588,7 @@ const PromptAuditPage = () => {
} finally {
setSaving(false)
}
- }, [commitSavedSnapshot, config, saving, t])
+ }, [commitSavedSnapshot, config, loadRuntime, saving, t])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -1216,12 +1260,23 @@ const PromptAuditPage = () => {
icon={<ShieldCheck size={18} />}
actions={
<div className='security-audit-header-status'>
- <Tag color={config.enabled ? 'green' : 'grey'} size='large'>
- {config.enabled ? t('审计已启用') : t('审计未启用')}
+ <Tag color={runtimeAuditEnabled ? 'green' : 'grey'} size='large'>
+ {runtimeAuditEnabled ? t('审计已启用') : t('审计未启用')}
</Tag>
<Tag color={runtimeStatusTag.color} size='large'>
{runtime?.process_status || runtimeStatusTag.label}
</Tag>
+ <Tag
+ color={runtimeEffectiveMode === 'blocking' ? 'red' : 'blue'}
+ size='large'
+ >
+ {runtimeModeLabel}
+ </Tag>
+ {runtimeVersionMismatch ? (
+ <Tag color='orange' size='large'>
+ {t('配置版本未同步')}
+ </Tag>
+ ) : null}
{isDirty ? (
<Tag color='orange' size='large'>
{t('有未保存更改')}
@@ -1292,7 +1347,9 @@ const PromptAuditPage = () => {
<div className='prompt-audit-overview__chips'>
<Tag>{`${t('Worker 数')}: ${config.workerCount}`}</Tag>
<Tag>{`${t('队列容量')}: ${config.queueCapacity}`}</Tag>
- <Tag>{`${t('调度策略')}: ${config.strategy === 'round_robin' ? t('轮询') : t('优先级')}`}</Tag>
+ <Tag>{`${t('调度策略')}: ${t('优先级故障切换')}`}</Tag>
+ <Tag>{`${t('期望版本')}: ${runtime?.expected_config_version || config.configVersion}`}</Tag>
+ <Tag>{`${t('生效版本')}: ${runtime?.active_config_version || 0}`}</Tag>
</div>
<div className='prompt-audit-event-row'>
<code>prompt_audit.started</code>
@@ -1348,20 +1405,7 @@ const PromptAuditPage = () => {
</div>
</div>
<Space wrap>
- <Select
- value={config.strategy}
- onChange={(value) =>
- updateConfig('strategy', String(value))
- }
- style={{ width: 180 }}
- >
- <Select.Option value='priority'>
- {t('优先级')}
- </Select.Option>
- <Select.Option value='round_robin'>
- {t('轮询')}
- </Select.Option>
- </Select>
+ <Tag color='blue'>{t('优先级故障切换')}</Tag>
<Button icon={<Plus size={16} />} onClick={addEndpoint}>
{t('添加审计池')}
</Button>
@@ -2521,8 +2565,27 @@ const PromptAuditPage = () => {
</span>
<Switch
checked={config.enabled}
- onChange={(enabled) => updateConfig('enabled', enabled)}
+ onChange={updateAuditEnabled}
+ size='small'
+ aria-label={t('启用审计')}
+ />
+ </label>
+ <label
+ className='prompt-audit-save-bar__switch'
+ title={t(
+ '关闭时异步只审计;开启时请求在转发前等待 Guard,命中 Block 或 Guard 不可用都会阻止请求。',
+ )}
+ >
+ <span className='prompt-audit-switch-copy'>
+ <ShieldCheck size={14} />
+ <Text size='small'>{t('同步阻止')}</Text>
+ </span>
+ <Switch
+ checked={config.enabled && config.blockingEnabled}
+ disabled={!config.enabled}
+ onChange={updateBlockingEnabled}
size='small'
+ aria-label={t('同步阻止')}
/>
</label>
<label className='prompt-audit-save-bar__switch'>
diff --git a/webui/src/features/prompt-audit/promptAuditViewModel.test.ts b/webui/src/features/prompt-audit/promptAuditViewModel.test.ts
index b90cc77727e54a1c22552a1d9f054ef700732ed4..0163323fa9603bbe7854ebaf58cdf84cfd88d126 100644
--- a/webui/src/features/prompt-audit/promptAuditViewModel.test.ts
+++ b/webui/src/features/prompt-audit/promptAuditViewModel.test.ts
@@ -68,6 +68,8 @@ import type {
const configResponse = {
enabled: true,
+ blocking_enabled: true,
+ config_version: 7,
store_pass_events: false,
strategy: 'priority',
worker_count: 16,
@@ -333,6 +335,8 @@ describe('promptAuditViewModel', () => {
const config = normalizePromptAuditConfig(configResponse)
expect(config).toMatchObject({
enabled: true,
+ blockingEnabled: true,
+ configVersion: 7,
workerCount: '16',
queueCapacity: '10000',
scanners: ['Jailbreak', 'PII'],
@@ -349,11 +353,13 @@ describe('promptAuditViewModel', () => {
const snapshot = buildConfigSnapshot(config)
expect(parsePromptAuditSnapshot(snapshot)).toMatchObject({
enabled: true,
+ blockingEnabled: true,
auditGroupMode: 'selected',
})
expect(parsePromptAuditSnapshot('bad json')).toBeNull()
expect(buildPromptAuditConfigPayload(config)).toMatchObject({
enabled: true,
+ blocking_enabled: true,
worker_count: 16,
queue_capacity: 10000,
audit_group_mode: 'selected',
@@ -371,6 +377,26 @@ describe('promptAuditViewModel', () => {
})
})
+ it('旧配置响应缺少同步字段时安全回退到异步只审计', () => {
+ const legacy = normalizePromptAuditConfig({
+ ...configResponse,
+ blocking_enabled: undefined,
+ config_version: undefined,
+ strategy: 'round_robin',
+ })
+ expect(legacy).toMatchObject({
+ enabled: true,
+ blockingEnabled: false,
+ configVersion: 1,
+ strategy: 'priority',
+ })
+ expect(buildPromptAuditConfigPayload(legacy)).toMatchObject({
+ enabled: true,
+ blocking_enabled: false,
+ strategy: 'priority',
+ })
+ })
+
it('运行态、探测结果和端点选项保持旧语义', () => {
const config = normalizePromptAuditConfig(configResponse)
expect(endpointStatusFromConnectivity('ok', true)).toBe('healthy')
diff --git a/webui/src/features/prompt-audit/promptAuditViewModel.ts b/webui/src/features/prompt-audit/promptAuditViewModel.ts
index 9150a61a005585a8a98e5d2aef7c79bd466fa375..fd52612c7cc0761f9f00d09727c4f7f1abd5363c 100644
--- a/webui/src/features/prompt-audit/promptAuditViewModel.ts
+++ b/webui/src/features/prompt-audit/promptAuditViewModel.ts
@@ -151,6 +151,7 @@ export const DEFAULT_PROMPT_AUDIT_ENDPOINTS: PromptAuditEndpointState[] = [
export const DEFAULT_PROMPT_AUDIT_CONFIG: PromptAuditConfigState = {
enabled: false,
+ blockingEnabled: false,
storePassEvents: false,
strategy: 'priority',
workerCount: '4',
@@ -159,6 +160,7 @@ export const DEFAULT_PROMPT_AUDIT_CONFIG: PromptAuditConfigState = {
auditGroupMode: 'all',
auditGroups: [],
endpoints: DEFAULT_PROMPT_AUDIT_ENDPOINTS,
+ configVersion: 1,
}
export const DEFAULT_PROMPT_AUDIT_FILTERS: PromptAuditFilters = {
@@ -793,8 +795,9 @@ export const normalizePromptAuditConfig = (
}
return {
enabled: Boolean(data.enabled),
+ blockingEnabled: Boolean(data.enabled && data.blocking_enabled),
storePassEvents: Boolean(data.store_pass_events),
- strategy: data.strategy || 'priority',
+ strategy: 'priority',
workerCount: String(data.worker_count || 4),
queueCapacity: String(data.queue_capacity || 10000),
scanners:
@@ -804,6 +807,7 @@ export const normalizePromptAuditConfig = (
auditGroupMode: normalizeAuditGroupMode(data.audit_group_mode),
auditGroups: normalizeStringList(data.audit_groups),
endpoints: data.endpoints.map(normalizeEndpointFromAPI),
+ configVersion: Math.max(1, Number(data.config_version || 1)),
}
}
@@ -834,14 +838,16 @@ const endpointEditableSnapshot = (endpoint: PromptAuditEndpointState) => ({
export const buildConfigSnapshot = (config: PromptAuditConfigState): string =>
JSON.stringify({
enabled: config.enabled,
+ blockingEnabled: config.enabled && config.blockingEnabled,
storePassEvents: config.storePassEvents,
- strategy: config.strategy || 'priority',
+ strategy: 'priority',
workerCount: String(config.workerCount ?? ''),
queueCapacity: String(config.queueCapacity ?? ''),
scanners: normalizeStringList(config.scanners).sort(),
auditGroupMode: normalizeAuditGroupMode(config.auditGroupMode),
auditGroups: normalizeStringList(config.auditGroups).sort(),
endpoints: config.endpoints.map(endpointEditableSnapshot),
+ configVersion: Math.max(1, Number(config.configVersion || 1)),
})
export const parsePromptAuditSnapshot = (
@@ -876,8 +882,9 @@ export const buildPromptAuditConfigPayload = (
config: PromptAuditConfigState,
): PromptAuditConfigSavePayload => ({
enabled: config.enabled,
+ blocking_enabled: config.enabled && config.blockingEnabled,
store_pass_events: config.storePassEvents,
- strategy: config.strategy,
+ strategy: 'priority',
worker_count: toPositiveInt(config.workerCount, 4),
queue_capacity: toPositiveInt(config.queueCapacity, 10000),
scanners: normalizeStringList(config.scanners),
diff --git a/webui/src/types/promptAudit.ts b/webui/src/types/promptAudit.ts
index c99e817e57766960b3f0467572554ddf9bd48771..de68778d5b4af479952f455dd3c495fb49bb5be6 100644
--- a/webui/src/types/promptAudit.ts
+++ b/webui/src/types/promptAudit.ts
@@ -81,6 +81,7 @@ export interface PromptAuditEndpointState {
export interface PromptAuditConfigResponse {
enabled?: boolean
+ blocking_enabled?: boolean
store_pass_events?: boolean
strategy?: string
worker_count?: number
@@ -89,10 +90,14 @@ export interface PromptAuditConfigResponse {
audit_group_mode?: string
audit_groups?: string[]
endpoints?: PromptAuditEndpointAPI[]
+ config_version?: number
+ updated_at?: string
+ change_summary?: string
}
export interface PromptAuditConfigState {
enabled: boolean
+ blockingEnabled: boolean
storePassEvents: boolean
strategy: string
workerCount: string
@@ -101,6 +106,7 @@ export interface PromptAuditConfigState {
auditGroupMode: PromptAuditGroupMode
auditGroups: string[]
endpoints: PromptAuditEndpointState[]
+ configVersion: number
}
export interface PromptAuditEndpointSavePayload {
@@ -119,6 +125,7 @@ export interface PromptAuditEndpointSavePayload {
export interface PromptAuditConfigSavePayload {
enabled: boolean
+ blocking_enabled: boolean
store_pass_events: boolean
strategy: string
worker_count: number
@@ -131,6 +138,13 @@ export interface PromptAuditConfigSavePayload {
export interface PromptAuditRuntime {
enabled?: boolean
+ blocking_enabled?: boolean
+ effective_mode?: 'off' | 'async_audit' | 'blocking' | string
+ expected_config_version?: number
+ active_config_version?: number
+ config_loaded_at?: string
+ config_load_error?: string
+ config_load_error_at?: string
process_status?: string
queue_length?: number
queue_capacity?: number