| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771 |
- 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 返回 403,Guard 不可用或输出非法返回 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 只允许 HTTPS;HTTP 仅允许 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
|