aicodex-prompt-audit-tracked.patch 122 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771
  1. diff --git a/ai-gateway/cmd/aicodex/main.go b/ai-gateway/cmd/aicodex/main.go
  2. index 59d11dcb90f59c9868ca836b2acf1a827d78eeb0..e57948b376df63083dd1c24dd3a778707a9793f3 100644
  3. --- a/ai-gateway/cmd/aicodex/main.go
  4. +++ b/ai-gateway/cmd/aicodex/main.go
  5. @@ -85,7 +85,7 @@ var (
  6. migrationInitLogDBFn = model.InitLogDB
  7. migrationCloseDBFn = model.CloseDB
  8. promptAuditRunnerFactory = func() *promptaudit.Runner {
  9. - return promptaudit.NewRunner(nil, nil, promptaudit.NewOpenAICompatibleClient(service.GetHttpClient()), nil)
  10. + return promptaudit.NewRunner(nil, nil, promptaudit.NewOpenAICompatibleClient(nil), nil)
  11. }
  12. )
  13. diff --git a/ai-gateway/internal/controller/prompt_audit.go b/ai-gateway/internal/controller/prompt_audit.go
  14. index 25a71a76ee20d9f6a950074c6dc83995a67fe75d..dc34813b01f25864c5451ac3f947e1331b2e1672 100644
  15. --- a/ai-gateway/internal/controller/prompt_audit.go
  16. +++ b/ai-gateway/internal/controller/prompt_audit.go
  17. @@ -1,6 +1,7 @@
  18. package controller
  19. import (
  20. + "errors"
  21. "net/http"
  22. "strconv"
  23. "strings"
  24. @@ -9,6 +10,7 @@ import (
  25. "github.com/gin-gonic/gin"
  26. appent "github.com/mt21625457/aicodex/ent"
  27. "github.com/mt21625457/aicodex/internal/common"
  28. + "github.com/mt21625457/aicodex/internal/constant"
  29. "github.com/mt21625457/aicodex/internal/service/promptaudit"
  30. )
  31. @@ -48,12 +50,21 @@ type promptAuditEventFilterRequest struct {
  32. var (
  33. previewDeletePromptAuditEventsByFilter = promptaudit.PreviewDeleteEventsByFilter
  34. deletePromptAuditEventsByFilter = promptaudit.DeleteEventsByFilter
  35. + promptAuditConfigServiceFactory = func() *promptaudit.ConfigService { return promptaudit.NewConfigService(nil) }
  36. )
  37. func GetPromptAuditConfig(c *gin.Context) {
  38. - cfg, err := promptaudit.NewConfigService(nil).Public(c.Request.Context())
  39. + cfg, err := promptAuditConfigServiceFactory().Public(c.Request.Context())
  40. if err != nil {
  41. - common.ApiError(c, err)
  42. + promptaudit.LogWarnEvent(
  43. + "prompt_guard.config_reload_degraded",
  44. + promptAuditLogFields(c,
  45. + promptaudit.Field("status", "degraded"),
  46. + promptaudit.Field("error_code", "config_read_failed"),
  47. + promptaudit.Field("error_kind", "config_read_failed"),
  48. + )...,
  49. + )
  50. + common.ApiErrorMsg(c, "读取提示词审计配置失败")
  51. return
  52. }
  53. common.ApiSuccess(c, cfg)
  54. @@ -68,8 +79,17 @@ func UpdatePromptAuditConfig(c *gin.Context) {
  55. common.ApiErrorMsg(c, "invalid request body")
  56. return
  57. }
  58. - cfg, err := promptaudit.NewConfigService(nil).Save(c.Request.Context(), req)
  59. + req.UpdatedBy = common.GetContextKeyInt(c, constant.ContextKeyUserId)
  60. + cfg, err := promptAuditConfigServiceFactory().Save(c.Request.Context(), req)
  61. if err != nil {
  62. + var validationErr *promptaudit.ConfigValidationError
  63. + if errors.As(err, &validationErr) {
  64. + recordAdminAuditFailed(c, "prompt_audit.config.update", "prompt_audit_config", "global", validationErr.Code, promptAuditAdminAuditDetail(map[string]any{
  65. + "enabled": req.Enabled, "blocking_enabled": req.BlockingEnabled,
  66. + }))
  67. + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": validationErr.Message, "code": validationErr.Code})
  68. + return
  69. + }
  70. recordAdminAuditFailed(c, "prompt_audit.config.update", "prompt_audit_config", "global", "save_config_failed", promptAuditAdminAuditDetail(map[string]any{
  71. "enabled": req.Enabled,
  72. "endpoint_count": len(req.Endpoints),
  73. @@ -77,7 +97,7 @@ func UpdatePromptAuditConfig(c *gin.Context) {
  74. "audit_group_mode": req.AuditGroupMode,
  75. "audit_group_count": len(req.AuditGroups),
  76. "audit_group_hash": promptaudit.Config{AuditGroups: req.AuditGroups}.AuditGroupHash(),
  77. - "error": err.Error(),
  78. + "error_code": "save_config_failed",
  79. }))
  80. promptaudit.LogWarnEvent(
  81. "prompt_audit.config_updated",
  82. @@ -91,15 +111,16 @@ func UpdatePromptAuditConfig(c *gin.Context) {
  83. promptaudit.Field("audit_group_count", len(req.AuditGroups)),
  84. promptaudit.Field("audit_group_hash", promptaudit.Config{AuditGroups: req.AuditGroups}.AuditGroupHash()),
  85. promptaudit.Field("error_code", "save_config_failed"),
  86. - promptaudit.Field("error_kind", err.Error()),
  87. + promptaudit.Field("error_kind", "config_save_failed"),
  88. )...,
  89. )
  90. - common.ApiErrorMsg(c, err.Error())
  91. + common.ApiErrorMsg(c, "保存提示词审计配置失败")
  92. return
  93. }
  94. - promptaudit.ClearConfigCache()
  95. recordAdminAuditSuccess(c, "prompt_audit.config.update", "prompt_audit_config", "global", promptAuditAdminAuditDetail(map[string]any{
  96. "enabled": cfg.Enabled,
  97. + "blocking_enabled": cfg.BlockingEnabled,
  98. + "config_version": cfg.ConfigVersion,
  99. "endpoint_count": len(cfg.Endpoints),
  100. "scanner_count": len(cfg.Scanners),
  101. "worker_count": cfg.WorkerCount,
  102. @@ -123,6 +144,16 @@ func UpdatePromptAuditConfig(c *gin.Context) {
  103. promptaudit.Field("audit_group_hash", cfg.AuditGroupHash()),
  104. )...,
  105. )
  106. + promptaudit.LogInfoEvent(
  107. + "prompt_guard.config_updated",
  108. + promptAuditLogFields(c,
  109. + promptaudit.Field("status", "success"),
  110. + promptaudit.Field("enabled", cfg.Enabled),
  111. + promptaudit.Field("blocking_enabled", cfg.BlockingEnabled),
  112. + promptaudit.Field("config_version", cfg.ConfigVersion),
  113. + promptaudit.Field("updated_by", req.UpdatedBy),
  114. + )...,
  115. + )
  116. common.ApiSuccess(c, cfg)
  117. }
  118. diff --git a/ai-gateway/internal/controller/prompt_audit_test.go b/ai-gateway/internal/controller/prompt_audit_test.go
  119. index 76ae4f4c27e2eec3220532fc1dadc562dd2ee9ce..f6e51234283e35a63c77d9ca4a83581c3ee693d8 100644
  120. --- a/ai-gateway/internal/controller/prompt_audit_test.go
  121. +++ b/ai-gateway/internal/controller/prompt_audit_test.go
  122. @@ -21,6 +21,90 @@ import (
  123. "github.com/mt21625457/aicodex/internal/testutil"
  124. )
  125. +type controllerPromptAuditOptionStore struct {
  126. + values map[string]string
  127. + getErr error
  128. + setErr error
  129. +}
  130. +
  131. +func (s *controllerPromptAuditOptionStore) GetOption(_ context.Context, key string) (string, bool, error) {
  132. + if s.getErr != nil {
  133. + return "", false, s.getErr
  134. + }
  135. + value, ok := s.values[key]
  136. + return value, ok, nil
  137. +}
  138. +
  139. +func (s *controllerPromptAuditOptionStore) SetOption(_ context.Context, key string, value string) error {
  140. + if s.setErr != nil {
  141. + return s.setErr
  142. + }
  143. + if s.values == nil {
  144. + s.values = make(map[string]string)
  145. + }
  146. + s.values[key] = value
  147. + return nil
  148. +}
  149. +
  150. +func withPromptAuditConfigServiceFactory(t *testing.T, store promptaudit.OptionStore) {
  151. + t.Helper()
  152. + previous := promptAuditConfigServiceFactory
  153. + promptAuditConfigServiceFactory = func() *promptaudit.ConfigService {
  154. + return promptaudit.NewConfigService(store)
  155. + }
  156. + t.Cleanup(func() {
  157. + promptAuditConfigServiceFactory = previous
  158. + })
  159. +}
  160. +
  161. +func TestPromptAuditConfigAPIRejectsInvalidBlockingCombination(t *testing.T) {
  162. + withPromptAuditConfigServiceFactory(t, &controllerPromptAuditOptionStore{values: map[string]string{}})
  163. + ctx, recorder := newPromptAuditRequestContext(t, http.MethodPut, "/api/prompt-audit/config", `{"enabled":false,"blocking_enabled":true,"strategy":"priority"}`)
  164. +
  165. + UpdatePromptAuditConfig(ctx)
  166. +
  167. + if recorder.Code != http.StatusBadRequest {
  168. + t.Fatalf("非法同步阻止组合应返回 400,实际 status=%d body=%s", recorder.Code, recorder.Body.String())
  169. + }
  170. + if body := recorder.Body.String(); !strings.Contains(body, promptaudit.PromptGuardRequiresAuditEnabled) {
  171. + t.Fatalf("非法同步阻止组合应返回稳定错误码,实际 %s", body)
  172. + }
  173. +}
  174. +
  175. +func TestPromptAuditConfigAPIMasksInternalReadAndSaveErrors(t *testing.T) {
  176. + const sensitiveInternalError = "postgres://admin:super-secret@db.internal/aicodex"
  177. +
  178. + t.Run("读取失败", func(t *testing.T) {
  179. + withPromptAuditConfigServiceFactory(t, &controllerPromptAuditOptionStore{getErr: errors.New(sensitiveInternalError)})
  180. + ctx, recorder := newPromptAuditRequestContext(t, http.MethodGet, "/api/prompt-audit/config", "")
  181. +
  182. + GetPromptAuditConfig(ctx)
  183. +
  184. + body := recorder.Body.String()
  185. + if strings.Contains(body, sensitiveInternalError) || strings.Contains(body, "super-secret") {
  186. + t.Fatalf("配置读取错误不得回显内部连接信息: %s", body)
  187. + }
  188. + if !strings.Contains(body, "读取提示词审计配置失败") {
  189. + t.Fatalf("配置读取错误应返回通用消息: %s", body)
  190. + }
  191. + })
  192. +
  193. + t.Run("保存失败", func(t *testing.T) {
  194. + withPromptAuditConfigServiceFactory(t, &controllerPromptAuditOptionStore{values: map[string]string{}, setErr: errors.New(sensitiveInternalError)})
  195. + ctx, recorder := newPromptAuditRequestContext(t, http.MethodPut, "/api/prompt-audit/config", `{"enabled":false,"blocking_enabled":false,"strategy":"priority"}`)
  196. +
  197. + UpdatePromptAuditConfig(ctx)
  198. +
  199. + body := recorder.Body.String()
  200. + if strings.Contains(body, sensitiveInternalError) || strings.Contains(body, "super-secret") {
  201. + t.Fatalf("配置保存错误不得回显内部连接信息: %s", body)
  202. + }
  203. + if !strings.Contains(body, "保存提示词审计配置失败") {
  204. + t.Fatalf("配置保存错误应返回通用消息: %s", body)
  205. + }
  206. + })
  207. +}
  208. +
  209. func TestPromptAuditConfigAPIStoresTokenAsSensitiveValue(t *testing.T) {
  210. withPromptAuditControllerTestDB(t, func() {
  211. gin.SetMode(gin.TestMode)
  212. diff --git a/ai-gateway/internal/gatewayadapter/transport/anthropic.go b/ai-gateway/internal/gatewayadapter/transport/anthropic.go
  213. index 5d15c6bd79bc5530094eb43fde9f47645819cb1a..10dce044a23e590534d6d410e8a46929225dae0c 100644
  214. --- a/ai-gateway/internal/gatewayadapter/transport/anthropic.go
  215. +++ b/ai-gateway/internal/gatewayadapter/transport/anthropic.go
  216. @@ -113,6 +113,7 @@ func NewAnthropicGatewayHandler(deps AnthropicGatewayDeps) http.Handler {
  217. v1.Use(gatewaycore.RegisterHTTPAuditPostHook())
  218. v1.Use(middleware.UserConcurrencyLimit())
  219. v1.Use(middleware.ModelRequestRateLimit())
  220. + v1.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatClaude))
  221. v1.Use(middleware.Distribute())
  222. v1.Use(middleware.PriorityAdmission())
  223. v1.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatClaude))
  224. diff --git a/ai-gateway/internal/gatewayadapter/transport/gemini.go b/ai-gateway/internal/gatewayadapter/transport/gemini.go
  225. index 53e58476a7fc6ec854766cfac995fb5a4254962e..24d0e12eb16b1fff373fc69eb65268718c81e043 100644
  226. --- a/ai-gateway/internal/gatewayadapter/transport/gemini.go
  227. +++ b/ai-gateway/internal/gatewayadapter/transport/gemini.go
  228. @@ -100,6 +100,7 @@ func NewGeminiGatewayHandler(deps GeminiGatewayDeps) http.Handler {
  229. relayRouter.Use(gatewaycore.RegisterHTTPAuditPostHook())
  230. relayRouter.Use(middleware.UserConcurrencyLimit())
  231. relayRouter.Use(middleware.ModelRequestRateLimit())
  232. + relayRouter.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatGemini))
  233. relayRouter.Use(middleware.Distribute())
  234. relayRouter.Use(middleware.PriorityAdmission())
  235. relayRouter.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatGemini))
  236. diff --git a/ai-gateway/internal/gatewayadapter/transport/jimeng.go b/ai-gateway/internal/gatewayadapter/transport/jimeng.go
  237. index 8e5ef822bda42b100d8376f635c651a606e4611a..7dbc008716c36ac1ba26d022f0156f82c505876c 100644
  238. --- a/ai-gateway/internal/gatewayadapter/transport/jimeng.go
  239. +++ b/ai-gateway/internal/gatewayadapter/transport/jimeng.go
  240. @@ -54,6 +54,7 @@ func NewJimengGatewayHandler(deps JimengGatewayDeps) http.Handler {
  241. jimeng.Use(middleware.JimengRequestConvert())
  242. jimeng.Use(middleware.TokenAuth())
  243. jimeng.Use(middleware.UserConcurrencyLimit())
  244. + jimeng.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatTask))
  245. jimeng.Use(middleware.Distribute())
  246. jimeng.Use(middleware.PriorityAdmission())
  247. jimeng.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask))
  248. diff --git a/ai-gateway/internal/gatewayadapter/transport/kling.go b/ai-gateway/internal/gatewayadapter/transport/kling.go
  249. index 875dd1157b3cc168da4ee0bfb5036bca98285b55..1c5e91c3deeb39e1e2ddfa2c5c0c2a721819bbba 100644
  250. --- a/ai-gateway/internal/gatewayadapter/transport/kling.go
  251. +++ b/ai-gateway/internal/gatewayadapter/transport/kling.go
  252. @@ -54,6 +54,7 @@ func NewKlingGatewayHandler(deps KlingGatewayDeps) http.Handler {
  253. kling.Use(middleware.KlingRequestConvert())
  254. kling.Use(middleware.TokenAuth())
  255. kling.Use(middleware.UserConcurrencyLimit())
  256. + kling.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatTask))
  257. kling.Use(middleware.Distribute())
  258. kling.Use(middleware.PriorityAdmission())
  259. kling.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask))
  260. diff --git a/ai-gateway/internal/gatewayadapter/transport/midjourney.go b/ai-gateway/internal/gatewayadapter/transport/midjourney.go
  261. index 216dbf70eda862db47766944e03de967c67a2ef7..5d4f3079b8d71791e2e0c113120159034ac3165b 100644
  262. --- a/ai-gateway/internal/gatewayadapter/transport/midjourney.go
  263. +++ b/ai-gateway/internal/gatewayadapter/transport/midjourney.go
  264. @@ -122,6 +122,7 @@ func registerMidjourneyTransportGroup(group *gin.RouterGroup, deps MidjourneyGat
  265. group.Use(middleware.TokenAuth())
  266. group.Use(middleware.UserConcurrencyLimit())
  267. + group.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatMjProxy))
  268. group.Use(middleware.Distribute())
  269. group.Use(middleware.PriorityAdmission())
  270. group.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatMjProxy))
  271. diff --git a/ai-gateway/internal/gatewayadapter/transport/openai.go b/ai-gateway/internal/gatewayadapter/transport/openai.go
  272. index d8fd0fc79ad78036a6540bf0c1c1e6b5a7b8fded..ea729a96b965174996e5b6f9df58519bfc1d1d09 100644
  273. --- a/ai-gateway/internal/gatewayadapter/transport/openai.go
  274. +++ b/ai-gateway/internal/gatewayadapter/transport/openai.go
  275. @@ -157,6 +157,7 @@ func NewOpenAIGatewayHandler(deps OpenAIGatewayDeps) http.Handler {
  276. httpRouter.Use(gatewaycore.RegisterHTTPAuditPostHook())
  277. httpRouter.Use(middleware.UserConcurrencyLimit())
  278. httpRouter.Use(middleware.ModelRequestRateLimit())
  279. + httpRouter.Use(promptaudit.HTTPGuardMiddleware())
  280. httpRouter.Use(middleware.Distribute())
  281. httpRouter.Use(middleware.PriorityAdmission())
  282. httpRouter.Use(promptaudit.HTTPEnqueueMiddleware())
  283. @@ -195,6 +196,7 @@ func NewOpenAIGatewayHandler(deps OpenAIGatewayDeps) http.Handler {
  284. responsesAliasRouter.Use(gatewaycore.RegisterHTTPAuditPostHook())
  285. responsesAliasRouter.Use(middleware.UserConcurrencyLimit())
  286. responsesAliasRouter.Use(middleware.ModelRequestRateLimit())
  287. + responsesAliasRouter.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatOpenAIResponses))
  288. responsesAliasRouter.Use(middleware.Distribute())
  289. responsesAliasRouter.Use(middleware.PriorityAdmission())
  290. responsesAliasRouter.Use(promptaudit.HTTPEnqueueMiddleware())
  291. diff --git a/ai-gateway/internal/gatewayadapter/transport/suno.go b/ai-gateway/internal/gatewayadapter/transport/suno.go
  292. index 714e282598547c5de444e145e44797ac25d7c4e4..e3532cc3c37ad5a73034a27c4ac9c9dfdd1092db 100644
  293. --- a/ai-gateway/internal/gatewayadapter/transport/suno.go
  294. +++ b/ai-gateway/internal/gatewayadapter/transport/suno.go
  295. @@ -65,6 +65,7 @@ func NewSunoGatewayHandler(deps SunoGatewayDeps) http.Handler {
  296. suno.Use(middleware.SystemPerformanceCheck())
  297. suno.Use(middleware.TokenAuth())
  298. suno.Use(middleware.UserConcurrencyLimit())
  299. + suno.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatTask))
  300. suno.Use(middleware.Distribute())
  301. suno.Use(middleware.PriorityAdmission())
  302. suno.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask))
  303. diff --git a/ai-gateway/internal/gatewayadapter/transport/task.go b/ai-gateway/internal/gatewayadapter/transport/task.go
  304. index 18f8c1fbdc6b039217fcd5b159a5a7c6e4a49480..9233198c44b17d864fb6243a2ec48a71e24bb2e0 100644
  305. --- a/ai-gateway/internal/gatewayadapter/transport/task.go
  306. +++ b/ai-gateway/internal/gatewayadapter/transport/task.go
  307. @@ -74,6 +74,7 @@ func NewTaskGatewayHandler(deps TaskGatewayDeps) http.Handler {
  308. v1 := engine.Group("/v1", relayMiddlewares...)
  309. v1.Use(middleware.TokenAuth())
  310. v1.Use(middleware.UserConcurrencyLimit())
  311. + v1.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatTask))
  312. v1.Use(middleware.Distribute())
  313. v1.Use(middleware.PriorityAdmission())
  314. v1.Use(promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask))
  315. diff --git a/ai-gateway/internal/gatewayadapter/transport/user_concurrency_order_test.go b/ai-gateway/internal/gatewayadapter/transport/user_concurrency_order_test.go
  316. index f2afd6df93ddac878b88af129ac6ba2965fe1418..ec93e96b6052b4e078754c59fb1ac2302e66a74c 100644
  317. --- a/ai-gateway/internal/gatewayadapter/transport/user_concurrency_order_test.go
  318. +++ b/ai-gateway/internal/gatewayadapter/transport/user_concurrency_order_test.go
  319. @@ -1,6 +1,8 @@
  320. package transport
  321. import (
  322. + "context"
  323. + "fmt"
  324. "go/ast"
  325. "go/parser"
  326. "go/token"
  327. @@ -18,9 +20,22 @@ import (
  328. "github.com/gin-gonic/gin"
  329. "github.com/go-redis/redis/v8"
  330. "github.com/mt21625457/aicodex/internal/common"
  331. + "github.com/mt21625457/aicodex/internal/gatewaycore"
  332. + "github.com/mt21625457/aicodex/internal/service/promptaudit"
  333. "github.com/mt21625457/aicodex/internal/setting"
  334. + "github.com/mt21625457/aicodex/internal/types"
  335. )
  336. +type transportPromptGuardEvaluator struct {
  337. + result gatewaycore.PromptGuardResult
  338. + calls atomic.Int32
  339. +}
  340. +
  341. +func (e *transportPromptGuardEvaluator) Evaluate(context.Context, gatewaycore.PromptGuardInput) gatewaycore.PromptGuardResult {
  342. + e.calls.Add(1)
  343. + return e.result
  344. +}
  345. +
  346. func TestTransportExecutionRoutesApplyUserConcurrencyAfterTokenAuthBeforeDistribute(t *testing.T) {
  347. tests := []struct {
  348. fileName string
  349. @@ -58,6 +73,203 @@ func TestTransportExecutionRoutesApplyUserConcurrencyAfterTokenAuthBeforeDistrib
  350. }
  351. }
  352. +func TestTransportPromptGuardRunsBeforeDistributionAndPriorityAdmission(t *testing.T) {
  353. + tests := []struct {
  354. + fileName string
  355. + funcName string
  356. + want []string
  357. + }{
  358. + {fileName: "openai.go", funcName: "NewOpenAIGatewayHandler", want: []string{"ModelRequestRateLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
  359. + {fileName: "anthropic.go", funcName: "NewAnthropicGatewayHandler", want: []string{"ModelRequestRateLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
  360. + {fileName: "gemini.go", funcName: "NewGeminiGatewayHandler", want: []string{"ModelRequestRateLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
  361. + {fileName: "suno.go", funcName: "NewSunoGatewayHandler", want: []string{"UserConcurrencyLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
  362. + {fileName: "midjourney.go", funcName: "registerMidjourneyTransportGroup", want: []string{"UserConcurrencyLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
  363. + {fileName: "kling.go", funcName: "NewKlingGatewayHandler", want: []string{"UserConcurrencyLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
  364. + {fileName: "jimeng.go", funcName: "NewJimengGatewayHandler", want: []string{"UserConcurrencyLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
  365. + {fileName: "task.go", funcName: "NewTaskGatewayHandler", want: []string{"UserConcurrencyLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission"}},
  366. + }
  367. + for _, tt := range tests {
  368. + t.Run(tt.fileName+"/"+tt.funcName, func(t *testing.T) {
  369. + order := middlewareCallOrderInFunction(t, tt.fileName, tt.funcName)
  370. + if !containsOrderedMiddlewareSequence(order, tt.want) {
  371. + t.Fatalf("同步门禁必须位于分流与优先级准入之前,want=%v order=%v", tt.want, order)
  372. + }
  373. + })
  374. + }
  375. +}
  376. +
  377. +func TestTransportPromptGuardBlocksSupported9068ProtocolsBeforeRelay(t *testing.T) {
  378. + gin.SetMode(gin.TestMode)
  379. + cleanup := setupDirectGatewayAuthDB(t)
  380. + defer cleanup()
  381. + seedAdminTokenAndChannel(t, "rawtransportguard", 9201)
  382. + installTransportPromptGuardConfig(t, true)
  383. +
  384. + evaluator := &transportPromptGuardEvaluator{result: gatewaycore.PromptGuardResult{
  385. + Decision: gatewaycore.PromptGuardDecisionBlock,
  386. + Action: gatewaycore.PromptGuardDecisionBlock,
  387. + ErrorCode: gatewaycore.PromptGuardErrorBlocked,
  388. + AllowNextStage: false,
  389. + }}
  390. + restoreEvaluator := promptaudit.SetPromptGuardEvaluatorForTesting(evaluator)
  391. + t.Cleanup(restoreEvaluator)
  392. +
  393. + tests := []struct {
  394. + name string
  395. + newHandler func(*atomic.Bool) http.Handler
  396. + target string
  397. + body string
  398. + marker string
  399. + }{
  400. + {
  401. + name: "OpenAI Chat",
  402. + newHandler: func(entered *atomic.Bool) http.Handler {
  403. + return NewOpenAIGatewayHandler(OpenAIGatewayDeps{RelayHTTP: func(c *gin.Context, _ interfaceRelayFormat) { entered.Store(true) }})
  404. + },
  405. + target: "/v1/chat/completions",
  406. + body: `{"model":"gpt-5","messages":[{"role":"user","content":"guard-secret-chat"}]}`,
  407. + marker: gatewaycore.PromptGuardErrorBlocked,
  408. + },
  409. + {
  410. + name: "OpenAI Responses alias",
  411. + newHandler: func(entered *atomic.Bool) http.Handler {
  412. + return NewOpenAIGatewayHandler(OpenAIGatewayDeps{RelayHTTP: func(c *gin.Context, _ interfaceRelayFormat) { entered.Store(true) }})
  413. + },
  414. + target: "/responses",
  415. + body: `{"model":"gpt-5","input":"guard-secret-responses"}`,
  416. + marker: gatewaycore.PromptGuardErrorBlocked,
  417. + },
  418. + {
  419. + name: "Claude Messages",
  420. + newHandler: func(_ *atomic.Bool) http.Handler {
  421. + return NewAnthropicGatewayHandler(AnthropicGatewayDeps{})
  422. + },
  423. + target: "/v1/messages",
  424. + body: `{"model":"claude-sonnet","messages":[{"role":"user","content":"guard-secret-claude"}]}`,
  425. + marker: `"type":"prompt_guard_blocked"`,
  426. + },
  427. + {
  428. + name: "Gemini",
  429. + newHandler: func(entered *atomic.Bool) http.Handler {
  430. + return NewGeminiGatewayHandler(GeminiGatewayDeps{RelayHTTP: func(c *gin.Context, _ interfaceRelayFormat) { entered.Store(true) }})
  431. + },
  432. + target: "/v1beta/models/gemini-2.5:streamGenerateContent",
  433. + body: `{"contents":[{"role":"user","parts":[{"text":"guard-secret-gemini"}]}]}`,
  434. + marker: `"reason":"prompt_guard_blocked"`,
  435. + },
  436. + {
  437. + name: "text task",
  438. + newHandler: func(entered *atomic.Bool) http.Handler {
  439. + return NewTaskGatewayHandler(TaskGatewayDeps{RelayTask: func(c *gin.Context) { entered.Store(true) }})
  440. + },
  441. + target: "/v1/videos",
  442. + body: `{"model":"sora-2","prompt":"guard-secret-task"}`,
  443. + marker: gatewaycore.PromptGuardErrorBlocked,
  444. + },
  445. + }
  446. +
  447. + for _, tt := range tests {
  448. + t.Run(tt.name, func(t *testing.T) {
  449. + var entered atomic.Bool
  450. + req := httptest.NewRequest(http.MethodPost, tt.target, strings.NewReader(tt.body))
  451. + req.Header.Set("Authorization", "Bearer sk-rawtransportguard-1")
  452. + req.Header.Set("Content-Type", gin.MIMEJSON)
  453. + req = req.WithContext(common.WithRequestEntrypoint(req.Context(), common.RequestEntrypointAI9068))
  454. + rec := httptest.NewRecorder()
  455. + tt.newHandler(&entered).ServeHTTP(rec, req)
  456. + if rec.Code != http.StatusForbidden || entered.Load() || !strings.Contains(rec.Body.String(), tt.marker) {
  457. + t.Fatalf("9068 同步门禁未在 relay 前阻止: status=%d entered=%v body=%s", rec.Code, entered.Load(), rec.Body.String())
  458. + }
  459. + for _, forbidden := range []string{"guard-secret-", "127.0.0.1:18080"} {
  460. + if strings.Contains(rec.Body.String(), forbidden) {
  461. + t.Fatalf("9068 错误响应泄露敏感信息 %q: %s", forbidden, rec.Body.String())
  462. + }
  463. + }
  464. + })
  465. + }
  466. + if got := evaluator.calls.Load(); got != int32(len(tests)) {
  467. + t.Fatalf("每个协议请求必须且仅调用一次同步 Guard: calls=%d want=%d", got, len(tests))
  468. + }
  469. +}
  470. +
  471. +func TestTransportPromptGuardDisabledKeeps9068AsynchronousAudit(t *testing.T) {
  472. + gin.SetMode(gin.TestMode)
  473. + cleanup := setupDirectGatewayAuthDB(t)
  474. + defer cleanup()
  475. + seedAdminTokenAndChannel(t, "rawtransportobserve", 9202)
  476. + installTransportPromptGuardConfig(t, false)
  477. +
  478. + evaluator := &transportPromptGuardEvaluator{result: gatewaycore.PromptGuardResult{
  479. + Decision: gatewaycore.PromptGuardDecisionUnavailable,
  480. + ErrorCode: gatewaycore.PromptGuardErrorUnavailable,
  481. + AllowNextStage: false,
  482. + }}
  483. + restoreEvaluator := promptaudit.SetPromptGuardEvaluatorForTesting(evaluator)
  484. + t.Cleanup(restoreEvaluator)
  485. + repo := promptaudit.NewMemoryRepository(promptaudit.EntRepository{})
  486. + restoreDefaults := promptaudit.SetDefaultsForTesting(repo, promptaudit.NewMemoryPayloadStore())
  487. + t.Cleanup(restoreDefaults)
  488. +
  489. + var entered atomic.Bool
  490. + handler := NewOpenAIGatewayHandler(OpenAIGatewayDeps{RelayHTTP: func(c *gin.Context, _ interfaceRelayFormat) {
  491. + entered.Store(true)
  492. + c.Status(http.StatusNoContent)
  493. + }})
  494. + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5","messages":[{"role":"user","content":"observe-only"}]}`))
  495. + req.Header.Set("Authorization", "Bearer sk-rawtransportobserve-1")
  496. + req.Header.Set("Content-Type", gin.MIMEJSON)
  497. + req = req.WithContext(common.WithRequestEntrypoint(req.Context(), common.RequestEntrypointAI9068))
  498. + rec := httptest.NewRecorder()
  499. + handler.ServeHTTP(rec, req)
  500. +
  501. + if rec.Code != http.StatusNoContent || !entered.Load() || evaluator.calls.Load() != 0 {
  502. + t.Fatalf("关闭同步阻止时必须继续原异步链路: status=%d entered=%v guard_calls=%d body=%s", rec.Code, entered.Load(), evaluator.calls.Load(), rec.Body.String())
  503. + }
  504. + deadline := time.Now().Add(time.Second)
  505. + for {
  506. + active, err := repo.CountActiveJobs(context.Background())
  507. + if err != nil {
  508. + t.Fatalf("读取异步审计队列失败: %v", err)
  509. + }
  510. + if active == 1 {
  511. + break
  512. + }
  513. + if time.Now().After(deadline) {
  514. + t.Fatalf("异步只审计模式未创建任务: active=%d", active)
  515. + }
  516. + time.Sleep(5 * time.Millisecond)
  517. + }
  518. +}
  519. +
  520. +// interfaceRelayFormat 是测试回调中 types.RelayFormat 的本地别名,避免与标准库类型混淆。
  521. +type interfaceRelayFormat = types.RelayFormat
  522. +
  523. +func installTransportPromptGuardConfig(t *testing.T, blocking bool) {
  524. + t.Helper()
  525. + common.OptionMapRWMutex.Lock()
  526. + hadMap := common.OptionMap != nil
  527. + if common.OptionMap == nil {
  528. + common.OptionMap = map[string]string{}
  529. + }
  530. + previous, hadValue := common.OptionMap[promptaudit.ConfigOptionKey]
  531. + 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)
  532. + common.OptionMapRWMutex.Unlock()
  533. + promptaudit.ClearConfigCache()
  534. + t.Cleanup(func() {
  535. + common.OptionMapRWMutex.Lock()
  536. + if hadValue {
  537. + common.OptionMap[promptaudit.ConfigOptionKey] = previous
  538. + } else {
  539. + delete(common.OptionMap, promptaudit.ConfigOptionKey)
  540. + if !hadMap && len(common.OptionMap) == 0 {
  541. + common.OptionMap = nil
  542. + }
  543. + }
  544. + common.OptionMapRWMutex.Unlock()
  545. + promptaudit.ClearConfigCache()
  546. + })
  547. +}
  548. +
  549. func TestTaskLikeTransportsRejectExceededUserConcurrencyBeforeRelayHandler(t *testing.T) {
  550. gin.SetMode(gin.TestMode)
  551. cleanup := setupDirectGatewayAuthDB(t)
  552. @@ -222,7 +434,7 @@ func middlewareCallOrderInFunction(t *testing.T, fileName string, funcName strin
  553. return true
  554. }
  555. switch selector.Sel.Name {
  556. - case "TokenAuth", "UserConcurrencyLimit", "Distribute":
  557. + case "TokenAuth", "UserConcurrencyLimit", "ModelRequestRateLimit", "HTTPGuardMiddleware", "Distribute", "PriorityAdmission":
  558. order = append(order, selector.Sel.Name)
  559. }
  560. return true
  561. @@ -230,6 +442,23 @@ func middlewareCallOrderInFunction(t *testing.T, fileName string, funcName strin
  562. return order
  563. }
  564. +func containsOrderedMiddlewareSequence(items []string, sequence []string) bool {
  565. + if len(sequence) == 0 {
  566. + return true
  567. + }
  568. + matched := 0
  569. + for _, item := range items {
  570. + if item != sequence[matched] {
  571. + continue
  572. + }
  573. + matched++
  574. + if matched == len(sequence) {
  575. + return true
  576. + }
  577. + }
  578. + return false
  579. +}
  580. +
  581. func firstIndex(items []string, target string) int {
  582. for i, item := range items {
  583. if item == target {
  584. diff --git a/ai-gateway/internal/relay/ws_responses.go b/ai-gateway/internal/relay/ws_responses.go
  585. index 885822ed8deb240a0c4c83c9f36f3a1d60455b65..0cca8b2add6028411eb314c98eb1fc370954dc2f 100644
  586. --- a/ai-gateway/internal/relay/ws_responses.go
  587. +++ b/ai-gateway/internal/relay/ws_responses.go
  588. @@ -232,6 +232,8 @@ func IsWebSocketUpgradeRequest(r *http.Request) bool {
  589. }
  590. func WsResponsesHelper(c *gin.Context) *types.AICodexError {
  591. + // 测试和灰度会替换 hook;请求开始时固定函数快照,避免长连接结束阶段与 hook 回收并发读写。
  592. + recordChannelAffinity := responsesWSRecordChannelAffinity
  593. if !IsWebSocketUpgradeRequest(c.Request) {
  594. apiErr := types.NewErrorWithStatusCode(
  595. errors.New("WebSocket upgrade required (Upgrade: websocket)"),
  596. @@ -294,6 +296,19 @@ func WsResponsesHelper(c *gin.Context) *types.AICodexError {
  597. logResponsesWSSetupFailed(c, nil, "first_message", firstErr, coderws.StatusPolicyViolation, "invalid first response.create payload", nil)
  598. return firstErr
  599. }
  600. + firstGuardCheck := promptaudit.EvaluatePromptGuardBody(c, types.RelayFormatOpenAIResponsesWS, "/v1/responses", firstMessage, "first_turn", false)
  601. + if !firstGuardCheck.Allowed {
  602. + closeCode := coderws.StatusTryAgainLater
  603. + if firstGuardCheck.ErrorCode == "prompt_guard_blocked" {
  604. + closeCode = coderws.StatusCode(4403)
  605. + }
  606. + responsesWSCloseClient(clientConn, closeCode, firstGuardCheck.ErrorCode)
  607. + apiErr := promptGuardAICodexError(firstGuardCheck)
  608. + logResponsesWSSetupFailed(c, nil, "prompt_guard_first_turn", apiErr, closeCode, firstGuardCheck.ErrorCode, map[string]any{
  609. + "model": requestModel,
  610. + })
  611. + return apiErr
  612. + }
  613. prepared, prepErr := dataplaneopenai.PrepareWSForwarding(&dataplaneopenai.WSPrepareRequest{
  614. RequestModel: requestModel,
  615. @@ -355,7 +370,6 @@ func WsResponsesHelper(c *gin.Context) *types.AICodexError {
  616. return apiErr
  617. }
  618. promptaudit.MaybeEnqueueTurnFromGateway(c, types.RelayFormatOpenAIResponsesWS, firstMessage)
  619. -
  620. dialCtx := c.Request.Context()
  621. cancelDial := func() {}
  622. if runtimeSettings.UpstreamDialTimeout > 0 {
  623. @@ -461,6 +475,10 @@ func WsResponsesHelper(c *gin.Context) *types.AICodexError {
  624. if !isResponsesWSResponseCreatePayload(payload) {
  625. return nil
  626. }
  627. + guardCheck := promptaudit.EvaluatePromptGuardBody(c, types.RelayFormatOpenAIResponsesWS, "/v1/responses", payload, "subsequent_turn", false)
  628. + if !guardCheck.Allowed {
  629. + return promptGuardAICodexError(guardCheck)
  630. + }
  631. if apiErr := relaycommon.ValidateOpenAIPriorityMode(
  632. gjson.GetBytes(payload, "service_tier").String(),
  633. channelOtherSettings.IsOpenAIPriorityAllowed(),
  634. @@ -524,7 +542,7 @@ func WsResponsesHelper(c *gin.Context) *types.AICodexError {
  635. logger.LogWarnEvent(c.Request.Context(), event, fields)
  636. },
  637. RecordChannelAffinity: func(channelID int) {
  638. - responsesWSRecordChannelAffinity(c, channelID)
  639. + recordChannelAffinity(c, channelID)
  640. },
  641. })
  642. }
  643. @@ -841,6 +859,14 @@ func mapResponsesWSErrorToCloseCode(err error, stage string, upstreamStatusCode
  644. case errors.Is(err, context.DeadlineExceeded):
  645. return coderws.StatusTryAgainLater, "upstream timeout"
  646. case errors.As(err, &apiErr) && apiErr != nil:
  647. + switch apiErr.GetErrorCode() {
  648. + case types.ErrorCodePromptGuardBlocked:
  649. + return coderws.StatusCode(4403), string(types.ErrorCodePromptGuardBlocked)
  650. + case types.ErrorCodePromptGuardUnavailable:
  651. + return coderws.StatusTryAgainLater, string(types.ErrorCodePromptGuardUnavailable)
  652. + case types.ErrorCodePromptGuardInvalidResponse:
  653. + return coderws.StatusTryAgainLater, string(types.ErrorCodePromptGuardInvalidResponse)
  654. + }
  655. if apiErr.GetErrorCode() == types.ErrorCodeSubscriptionPolicyMissing {
  656. return coderws.StatusPolicyViolation, string(types.ErrorCodeSubscriptionPolicyMissing)
  657. }
  658. @@ -874,6 +900,22 @@ func mapResponsesWSErrorToCloseCode(err error, stage string, upstreamStatusCode
  659. return coderws.StatusInternalError, "upstream websocket proxy failed"
  660. }
  661. +func promptGuardAICodexError(check promptaudit.PromptGuardCheck) *types.AICodexError {
  662. + code := types.ErrorCodePromptGuardUnavailable
  663. + message := "提示词安全服务暂时不可用,请稍后重试"
  664. + if check.ErrorCode == "prompt_guard_blocked" {
  665. + code = types.ErrorCodePromptGuardBlocked
  666. + message = "请求因提示词安全策略被阻止"
  667. + } else if check.ErrorCode == "prompt_guard_invalid_response" {
  668. + code = types.ErrorCodePromptGuardInvalidResponse
  669. + }
  670. + status := check.StatusCode
  671. + if status == 0 {
  672. + status = http.StatusServiceUnavailable
  673. + }
  674. + return types.NewErrorWithStatusCode(errors.New(message), code, status, types.ErrOptionWithSkipRetry())
  675. +}
  676. +
  677. func mapGatewayResponsesWSErrorToCloseCode(apiErr *types.AICodexError) (coderws.StatusCode, string, bool) {
  678. if apiErr == nil {
  679. return 0, "", false
  680. diff --git a/ai-gateway/internal/router/relay-router.go b/ai-gateway/internal/router/relay-router.go
  681. index 6c20b82fdacf19dfa08492fac5ebd2c95cc2722d..6ecf9e3a6b931f8d7c3e97541a83d2b2ef54020e 100644
  682. --- a/ai-gateway/internal/router/relay-router.go
  683. +++ b/ai-gateway/internal/router/relay-router.go
  684. @@ -8,6 +8,7 @@ import (
  685. "github.com/mt21625457/aicodex/internal/controller"
  686. "github.com/mt21625457/aicodex/internal/middleware"
  687. "github.com/mt21625457/aicodex/internal/relay"
  688. + "github.com/mt21625457/aicodex/internal/service/promptaudit"
  689. "github.com/mt21625457/aicodex/internal/types"
  690. "github.com/gin-gonic/gin"
  691. @@ -103,6 +104,7 @@ func SetRelayRouter(router *gin.Engine) {
  692. httpRouter.Use(middleware.HTTPAuditTrackMultiIP())
  693. httpRouter.Use(middleware.UserConcurrencyLimit())
  694. httpRouter.Use(middleware.ModelRequestRateLimit())
  695. + httpRouter.Use(promptaudit.HTTPGuardMiddleware())
  696. httpRouter.Use(middleware.Distribute())
  697. httpRouter.Use(middleware.PriorityAdmission())
  698. httpRouter.Use(middleware.HTTPAudit())
  699. @@ -190,6 +192,7 @@ func SetRelayRouter(router *gin.Engine) {
  700. responsesAliasRouter.Use(middleware.HTTPAuditTrackMultiIP())
  701. responsesAliasRouter.Use(middleware.UserConcurrencyLimit())
  702. responsesAliasRouter.Use(middleware.ModelRequestRateLimit())
  703. + responsesAliasRouter.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatOpenAIResponses))
  704. responsesAliasRouter.Use(middleware.Distribute())
  705. responsesAliasRouter.Use(middleware.PriorityAdmission())
  706. responsesAliasRouter.Use(middleware.HTTPAudit())
  707. @@ -208,7 +211,7 @@ func SetRelayRouter(router *gin.Engine) {
  708. relaySunoRouter := router.Group("/suno", relayMiddlewares...)
  709. relaySunoRouter.Use(middleware.SystemPerformanceCheck())
  710. - relaySunoRouter.Use(middleware.TokenAuth(), middleware.UserConcurrencyLimit(), middleware.Distribute(), middleware.PriorityAdmission())
  711. + relaySunoRouter.Use(middleware.TokenAuth(), middleware.UserConcurrencyLimit(), promptaudit.HTTPGuardMiddleware(types.RelayFormatTask), middleware.Distribute(), middleware.PriorityAdmission(), promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask))
  712. {
  713. relaySunoRouter.POST("/submit/:action", controller.RelayTask)
  714. relaySunoRouter.POST("/fetch", controller.RelayTask)
  715. @@ -221,6 +224,7 @@ func SetRelayRouter(router *gin.Engine) {
  716. relayGeminiRouter.Use(middleware.HTTPAuditTrackMultiIP())
  717. relayGeminiRouter.Use(middleware.UserConcurrencyLimit())
  718. relayGeminiRouter.Use(middleware.ModelRequestRateLimit())
  719. + relayGeminiRouter.Use(promptaudit.HTTPGuardMiddleware(types.RelayFormatGemini))
  720. relayGeminiRouter.Use(middleware.Distribute())
  721. relayGeminiRouter.Use(middleware.PriorityAdmission())
  722. relayGeminiRouter.Use(middleware.HTTPAudit())
  723. @@ -237,7 +241,7 @@ func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) {
  724. imageRoute.Use(middleware.UserAuth())
  725. imageRoute.GET("/image/:id", relay.RelayMidjourneyImage)
  726. - relayMjRouter.Use(middleware.TokenAuth(), middleware.UserConcurrencyLimit(), middleware.Distribute(), middleware.PriorityAdmission())
  727. + relayMjRouter.Use(middleware.TokenAuth(), middleware.UserConcurrencyLimit(), promptaudit.HTTPGuardMiddleware(types.RelayFormatMjProxy), middleware.Distribute(), middleware.PriorityAdmission(), promptaudit.HTTPEnqueueMiddleware(types.RelayFormatMjProxy))
  728. {
  729. relayMjRouter.POST("/submit/action", controller.RelayMidjourney)
  730. relayMjRouter.POST("/submit/shorten", controller.RelayMidjourney)
  731. diff --git a/ai-gateway/internal/router/video-router.go b/ai-gateway/internal/router/video-router.go
  732. index b6a0168773f73df7995d19f4be63df4c3f9bce4b..29d55934963930d42700eaa8dffb462457b49bee 100644
  733. --- a/ai-gateway/internal/router/video-router.go
  734. +++ b/ai-gateway/internal/router/video-router.go
  735. @@ -3,13 +3,22 @@ package router
  736. import (
  737. "github.com/mt21625457/aicodex/internal/controller"
  738. "github.com/mt21625457/aicodex/internal/middleware"
  739. + "github.com/mt21625457/aicodex/internal/service/promptaudit"
  740. + "github.com/mt21625457/aicodex/internal/types"
  741. "github.com/gin-gonic/gin"
  742. )
  743. func SetVideoRouter(router *gin.Engine) {
  744. videoV1Router := router.Group("/v1")
  745. - videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute(), middleware.PriorityAdmission())
  746. + videoV1Router.Use(
  747. + middleware.TokenAuth(),
  748. + middleware.UserConcurrencyLimit(),
  749. + promptaudit.HTTPGuardMiddleware(types.RelayFormatTask),
  750. + middleware.Distribute(),
  751. + middleware.PriorityAdmission(),
  752. + promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask),
  753. + )
  754. {
  755. videoV1Router.GET("/videos/:task_id/content", controller.VideoProxy)
  756. videoV1Router.POST("/video/generations", controller.RelayTask)
  757. @@ -24,7 +33,15 @@ func SetVideoRouter(router *gin.Engine) {
  758. }
  759. klingV1Router := router.Group("/kling/v1")
  760. - klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute(), middleware.PriorityAdmission())
  761. + klingV1Router.Use(
  762. + middleware.KlingRequestConvert(),
  763. + middleware.TokenAuth(),
  764. + middleware.UserConcurrencyLimit(),
  765. + promptaudit.HTTPGuardMiddleware(types.RelayFormatTask),
  766. + middleware.Distribute(),
  767. + middleware.PriorityAdmission(),
  768. + promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask),
  769. + )
  770. {
  771. klingV1Router.POST("/videos/text2video", controller.RelayTask)
  772. klingV1Router.POST("/videos/image2video", controller.RelayTask)
  773. @@ -34,7 +51,15 @@ func SetVideoRouter(router *gin.Engine) {
  774. // Jimeng official API routes - direct mapping to official API format
  775. jimengOfficialGroup := router.Group("jimeng")
  776. - jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute(), middleware.PriorityAdmission())
  777. + jimengOfficialGroup.Use(
  778. + middleware.JimengRequestConvert(),
  779. + middleware.TokenAuth(),
  780. + middleware.UserConcurrencyLimit(),
  781. + promptaudit.HTTPGuardMiddleware(types.RelayFormatTask),
  782. + middleware.Distribute(),
  783. + middleware.PriorityAdmission(),
  784. + promptaudit.HTTPEnqueueMiddleware(types.RelayFormatTask),
  785. + )
  786. {
  787. // Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31
  788. jimengOfficialGroup.POST("/", controller.RelayTask)
  789. diff --git a/ai-gateway/internal/service/promptaudit/client.go b/ai-gateway/internal/service/promptaudit/client.go
  790. index 91e18f5fb4ebb24a8a42c72826506f6bf5152493..941c54c35378d50d71bf49b8c62906bab2663cb3 100644
  791. --- a/ai-gateway/internal/service/promptaudit/client.go
  792. +++ b/ai-gateway/internal/service/promptaudit/client.go
  793. @@ -47,15 +47,8 @@ func newLLMGuardError(code string, message string, retryable bool, statusCode in
  794. }
  795. func readSmallResponseBody(body io.Reader) string {
  796. - if body == nil {
  797. - return ""
  798. - }
  799. - data, _ := io.ReadAll(io.LimitReader(body, 4096))
  800. - message := strings.TrimSpace(string(data))
  801. - if message == "" {
  802. - return "审计 API 返回非成功状态"
  803. - }
  804. - return message
  805. + _ = body
  806. + return "Guard API 返回非成功状态"
  807. }
  808. func firstNonEmptyString(values ...string) string {
  809. diff --git a/ai-gateway/internal/service/promptaudit/config.go b/ai-gateway/internal/service/promptaudit/config.go
  810. index db835a3704607700a65dad0b0b3cd7cbf4b8e6e0..f15a361f91441ed98f3f6edf4f1cffb7f1530ab1 100644
  811. --- a/ai-gateway/internal/service/promptaudit/config.go
  812. +++ b/ai-gateway/internal/service/promptaudit/config.go
  813. @@ -5,13 +5,13 @@ import (
  814. "crypto/sha256"
  815. "encoding/hex"
  816. "encoding/json"
  817. - "errors"
  818. "fmt"
  819. - "net/url"
  820. "os"
  821. "slices"
  822. "strconv"
  823. "strings"
  824. + "sync"
  825. + "sync/atomic"
  826. "time"
  827. "github.com/mt21625457/aicodex/internal/common"
  828. @@ -20,6 +20,12 @@ import (
  829. const ConfigOptionKey = "PromptAuditConfigJSON"
  830. +const (
  831. + PromptGuardRequiresAuditEnabled = "prompt_guard_requires_audit_enabled"
  832. + PromptGuardInvalidStrategy = "prompt_guard_invalid_strategy"
  833. + PromptAuditConfigInvalid = "prompt_audit_config_invalid"
  834. +)
  835. +
  836. const (
  837. AuditGroupModeAll = "all"
  838. AuditGroupModeSelected = "selected"
  839. @@ -32,12 +38,10 @@ const (
  840. )
  841. var (
  842. - allowedStrategies = map[string]struct{}{
  843. - "priority": {},
  844. - "weighted": {},
  845. - "shadow": {},
  846. - }
  847. - codeContentScanners = map[string]struct{}{
  848. + configSaveMu sync.Mutex
  849. + strategyMigrationLogged atomic.Bool
  850. + allowedStrategies = map[string]struct{}{"priority": {}}
  851. + codeContentScanners = map[string]struct{}{
  852. "bancode": {},
  853. "code": {},
  854. }
  855. @@ -117,6 +121,7 @@ type EndpointConfig struct {
  856. type Config struct {
  857. Enabled bool `json:"enabled"`
  858. + BlockingEnabled bool `json:"blocking_enabled"`
  859. StorePassEvents bool `json:"store_pass_events"`
  860. Strategy string `json:"strategy"`
  861. WorkerCount int `json:"worker_count"`
  862. @@ -125,6 +130,9 @@ type Config struct {
  863. AuditGroupMode string `json:"audit_group_mode"`
  864. AuditGroups []string `json:"audit_groups"`
  865. Endpoints []EndpointConfig `json:"endpoints"`
  866. + ConfigVersion int64 `json:"config_version"`
  867. + UpdatedBy int `json:"updated_by,omitempty"`
  868. + ChangeSummary string `json:"change_summary,omitempty"`
  869. UpdatedAt time.Time `json:"updated_at"`
  870. }
  871. @@ -147,6 +155,7 @@ type EndpointInput struct {
  872. type SaveConfigRequest struct {
  873. Enabled bool `json:"enabled"`
  874. + BlockingEnabled bool `json:"blocking_enabled"`
  875. StorePassEvents bool `json:"store_pass_events"`
  876. Strategy string `json:"strategy"`
  877. WorkerCount int `json:"worker_count"`
  878. @@ -155,6 +164,8 @@ type SaveConfigRequest struct {
  879. AuditGroupMode string `json:"audit_group_mode"`
  880. AuditGroups []string `json:"audit_groups"`
  881. Endpoints []EndpointInput `json:"endpoints"`
  882. + UpdatedBy int `json:"-"`
  883. + ChangeReason string `json:"change_reason"`
  884. }
  885. type EndpointPublic struct {
  886. @@ -176,6 +187,7 @@ type EndpointPublic struct {
  887. type PublicConfig struct {
  888. Enabled bool `json:"enabled"`
  889. + BlockingEnabled bool `json:"blocking_enabled"`
  890. StorePassEvents bool `json:"store_pass_events"`
  891. Strategy string `json:"strategy"`
  892. WorkerCount int `json:"worker_count"`
  893. @@ -184,11 +196,15 @@ type PublicConfig struct {
  894. AuditGroupMode string `json:"audit_group_mode"`
  895. AuditGroups []string `json:"audit_groups"`
  896. Endpoints []EndpointPublic `json:"endpoints"`
  897. + ConfigVersion int64 `json:"config_version"`
  898. + UpdatedBy int `json:"updated_by,omitempty"`
  899. + ChangeSummary string `json:"change_summary,omitempty"`
  900. UpdatedAt time.Time `json:"updated_at"`
  901. }
  902. type storedConfig struct {
  903. Enabled bool `json:"enabled"`
  904. + BlockingEnabled bool `json:"blocking_enabled,omitempty"`
  905. StorePassEvents bool `json:"store_pass_events"`
  906. Strategy string `json:"strategy"`
  907. WorkerCount int `json:"worker_count"`
  908. @@ -197,9 +213,25 @@ type storedConfig struct {
  909. AuditGroupMode string `json:"audit_group_mode,omitempty"`
  910. AuditGroups []string `json:"audit_groups,omitempty"`
  911. Endpoints []storedEndpoint `json:"endpoints"`
  912. + ConfigVersion int64 `json:"config_version,omitempty"`
  913. + UpdatedBy int `json:"updated_by,omitempty"`
  914. + ChangeSummary string `json:"change_summary,omitempty"`
  915. UpdatedAt time.Time `json:"updated_at"`
  916. }
  917. +// ConfigValidationError 为控制面提供稳定、可测试的配置错误码。
  918. +type ConfigValidationError struct {
  919. + Code string
  920. + Message string
  921. +}
  922. +
  923. +func (e *ConfigValidationError) Error() string {
  924. + if e == nil {
  925. + return ""
  926. + }
  927. + return e.Message
  928. +}
  929. +
  930. type storedEndpoint struct {
  931. ID string `json:"id"`
  932. Name string `json:"name"`
  933. @@ -218,14 +250,16 @@ type storedEndpoint struct {
  934. func DefaultConfig() Config {
  935. cfg := Config{
  936. - Enabled: false,
  937. - Strategy: "priority",
  938. - WorkerCount: 4,
  939. - QueueCapacity: 10000,
  940. - Scanners: defaultOpenAICompatibleScanners(),
  941. - AuditGroupMode: AuditGroupModeAll,
  942. - AuditGroups: []string{},
  943. - Endpoints: []EndpointConfig{},
  944. + Enabled: false,
  945. + BlockingEnabled: false,
  946. + Strategy: "priority",
  947. + WorkerCount: 4,
  948. + QueueCapacity: 10000,
  949. + Scanners: defaultOpenAICompatibleScanners(),
  950. + AuditGroupMode: AuditGroupModeAll,
  951. + AuditGroups: []string{},
  952. + Endpoints: []EndpointConfig{},
  953. + ConfigVersion: 1,
  954. }
  955. applyEnvDefaults(&cfg)
  956. return cfg
  957. @@ -255,6 +289,8 @@ func (s *ConfigService) Public(ctx context.Context) (PublicConfig, error) {
  958. }
  959. func (s *ConfigService) Save(ctx context.Context, req SaveConfigRequest) (PublicConfig, error) {
  960. + configSaveMu.Lock()
  961. + defer configSaveMu.Unlock()
  962. current, err := s.Load(ctx)
  963. if err != nil {
  964. return PublicConfig{}, err
  965. @@ -274,9 +310,22 @@ func (s *ConfigService) Save(ctx context.Context, req SaveConfigRequest) (Public
  966. if err := s.store.SetOption(ctx, ConfigOptionKey, string(body)); err != nil {
  967. return PublicConfig{}, err
  968. }
  969. + if configStorePublishesRuntime(s.store) {
  970. + installConfigSnapshot(cfg)
  971. + publishConfigInvalidation(ctx, cfg.ConfigVersion)
  972. + }
  973. return cfg.Public(), nil
  974. }
  975. +func configStorePublishesRuntime(store OptionStore) bool {
  976. + switch store.(type) {
  977. + case ModelOptionStore, *ModelOptionStore:
  978. + return true
  979. + default:
  980. + return false
  981. + }
  982. +}
  983. +
  984. func (c Config) Public() PublicConfig {
  985. endpoints := make([]EndpointPublic, 0, len(c.Endpoints))
  986. for _, endpoint := range c.Endpoints {
  987. @@ -304,6 +353,7 @@ func (c Config) Public() PublicConfig {
  988. }
  989. return PublicConfig{
  990. Enabled: c.Enabled,
  991. + BlockingEnabled: c.Enabled && c.BlockingEnabled,
  992. StorePassEvents: c.StorePassEvents,
  993. Strategy: c.Strategy,
  994. WorkerCount: c.WorkerCount,
  995. @@ -312,19 +362,35 @@ func (c Config) Public() PublicConfig {
  996. AuditGroupMode: normalizeAuditGroupMode(c.AuditGroupMode),
  997. AuditGroups: normalizeAuditGroups(c.AuditGroups),
  998. Endpoints: endpoints,
  999. + ConfigVersion: normalizeConfigVersion(c.ConfigVersion),
  1000. + UpdatedBy: c.UpdatedBy,
  1001. + ChangeSummary: c.ChangeSummary,
  1002. UpdatedAt: c.UpdatedAt,
  1003. }
  1004. }
  1005. func configFromStorage(stored storedConfig) (Config, error) {
  1006. + storedStrategy := strings.ToLower(strings.TrimSpace(stored.Strategy))
  1007. + if storedStrategy != "" && storedStrategy != "priority" && strategyMigrationLogged.CompareAndSwap(false, true) {
  1008. + LogWarnEvent(
  1009. + "prompt_guard.config_loaded",
  1010. + Field("status", "migrated"),
  1011. + Field("error_code", "historical_strategy_migrated"),
  1012. + Field("strategy", "priority"),
  1013. + )
  1014. + }
  1015. cfg := Config{
  1016. Enabled: stored.Enabled,
  1017. + BlockingEnabled: stored.Enabled && stored.BlockingEnabled,
  1018. StorePassEvents: stored.StorePassEvents,
  1019. Strategy: normalizeStrategy(stored.Strategy),
  1020. WorkerCount: normalizeWorkerCount(stored.WorkerCount),
  1021. QueueCapacity: normalizeQueueCapacity(stored.QueueCapacity),
  1022. AuditGroupMode: normalizeAuditGroupMode(stored.AuditGroupMode),
  1023. AuditGroups: normalizeAuditGroups(stored.AuditGroups),
  1024. + ConfigVersion: normalizeConfigVersion(stored.ConfigVersion),
  1025. + UpdatedBy: stored.UpdatedBy,
  1026. + ChangeSummary: strings.TrimSpace(stored.ChangeSummary),
  1027. UpdatedAt: stored.UpdatedAt,
  1028. }
  1029. for _, endpoint := range stored.Endpoints {
  1030. @@ -365,6 +431,7 @@ func configFromStorage(stored storedConfig) (Config, error) {
  1031. func configToStorage(cfg Config) (storedConfig, error) {
  1032. stored := storedConfig{
  1033. Enabled: cfg.Enabled,
  1034. + BlockingEnabled: cfg.Enabled && cfg.BlockingEnabled,
  1035. StorePassEvents: cfg.StorePassEvents,
  1036. Strategy: normalizeStrategy(cfg.Strategy),
  1037. WorkerCount: normalizeWorkerCount(cfg.WorkerCount),
  1038. @@ -373,6 +440,9 @@ func configToStorage(cfg Config) (storedConfig, error) {
  1039. AuditGroupMode: normalizeAuditGroupMode(cfg.AuditGroupMode),
  1040. AuditGroups: normalizeAuditGroups(cfg.AuditGroups),
  1041. Endpoints: make([]storedEndpoint, 0, len(cfg.Endpoints)),
  1042. + ConfigVersion: normalizeConfigVersion(cfg.ConfigVersion),
  1043. + UpdatedBy: cfg.UpdatedBy,
  1044. + ChangeSummary: strings.TrimSpace(cfg.ChangeSummary),
  1045. UpdatedAt: cfg.UpdatedAt,
  1046. }
  1047. for _, endpoint := range cfg.Endpoints {
  1048. @@ -400,14 +470,34 @@ func configToStorage(cfg Config) (storedConfig, error) {
  1049. }
  1050. func normalizeSaveRequest(req SaveConfigRequest, current Config) (Config, error) {
  1051. + if !req.Enabled && req.BlockingEnabled {
  1052. + return Config{}, &ConfigValidationError{
  1053. + Code: PromptGuardRequiresAuditEnabled,
  1054. + Message: "启用同步阻止前必须先启用提示词审计",
  1055. + }
  1056. + }
  1057. + strategy := strings.ToLower(strings.TrimSpace(req.Strategy))
  1058. + if strategy == "" {
  1059. + strategy = "priority"
  1060. + }
  1061. + if _, ok := allowedStrategies[strategy]; !ok {
  1062. + return Config{}, &ConfigValidationError{
  1063. + Code: PromptGuardInvalidStrategy,
  1064. + Message: "提示词审计调度策略仅支持 priority",
  1065. + }
  1066. + }
  1067. cfg := Config{
  1068. Enabled: req.Enabled,
  1069. + BlockingEnabled: req.Enabled && req.BlockingEnabled,
  1070. StorePassEvents: req.StorePassEvents,
  1071. - Strategy: normalizeStrategy(req.Strategy),
  1072. + Strategy: strategy,
  1073. WorkerCount: normalizeWorkerCount(req.WorkerCount),
  1074. QueueCapacity: normalizeQueueCapacity(req.QueueCapacity),
  1075. AuditGroupMode: normalizeAuditGroupMode(req.AuditGroupMode),
  1076. AuditGroups: normalizeAuditGroups(req.AuditGroups),
  1077. + ConfigVersion: normalizeConfigVersion(current.ConfigVersion) + 1,
  1078. + UpdatedBy: req.UpdatedBy,
  1079. + ChangeSummary: buildConfigChangeSummary(req, current),
  1080. UpdatedAt: time.Now().UTC(),
  1081. }
  1082. if strings.TrimSpace(req.AuditGroupMode) == "" {
  1083. @@ -426,7 +516,10 @@ func normalizeSaveRequest(req SaveConfigRequest, current Config) (Config, error)
  1084. cfg.Endpoints = append(cfg.Endpoints, endpoint)
  1085. }
  1086. if cfg.Enabled && !hasEnabledEndpointWithScanURL(cfg.Endpoints) {
  1087. - return Config{}, errors.New("启用提示词审计前至少需要一个启用且配置了 Base URL 的 OpenAI 兼容审计池")
  1088. + return Config{}, &ConfigValidationError{
  1089. + Code: PromptAuditConfigInvalid,
  1090. + Message: "启用提示词审计前至少需要一个启用且配置了 Base URL 的 OpenAI 兼容审计池",
  1091. + }
  1092. }
  1093. cfg.Scanners = normalizeScanners(req.Scanners)
  1094. return cfg, nil
  1095. @@ -454,11 +547,16 @@ func normalizeEndpointInput(input EndpointInput, index int, currentByID map[stri
  1096. }
  1097. scanURL = normalizeOpenAIChatCompletionsURL(firstNonEmptyString(baseURL, scanURL))
  1098. if baseURL == "" {
  1099. - return EndpointConfig{}, errors.New("OpenAI 兼容审计池 Base URL 不能为空")
  1100. + return EndpointConfig{}, &ConfigValidationError{
  1101. + Code: PromptAuditConfigInvalid,
  1102. + Message: "OpenAI 兼容审计池 Base URL 不能为空",
  1103. + }
  1104. }
  1105. - parsed, err := url.Parse(baseURL)
  1106. - if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
  1107. - return EndpointConfig{}, fmt.Errorf("OpenAI 兼容审计 Base URL 无效: %s", baseURL)
  1108. + if err := validateGuardBaseURL(baseURL); err != nil {
  1109. + return EndpointConfig{}, &ConfigValidationError{
  1110. + Code: PromptAuditConfigInvalid,
  1111. + Message: err.Error(),
  1112. + }
  1113. }
  1114. if model == "" && hasExisting {
  1115. model = existing.Model
  1116. @@ -512,6 +610,37 @@ func normalizeStrategy(value string) string {
  1117. return "priority"
  1118. }
  1119. +func normalizeConfigVersion(value int64) int64 {
  1120. + if value < 1 {
  1121. + return 1
  1122. + }
  1123. + return value
  1124. +}
  1125. +
  1126. +func buildConfigChangeSummary(req SaveConfigRequest, current Config) string {
  1127. + reason := strings.TrimSpace(req.ChangeReason)
  1128. + if len([]rune(reason)) > 120 {
  1129. + reason = string([]rune(reason)[:120])
  1130. + }
  1131. + mode := "异步只审计"
  1132. + if req.Enabled && req.BlockingEnabled {
  1133. + mode = "同步阻止"
  1134. + } else if !req.Enabled {
  1135. + mode = "关闭审计"
  1136. + }
  1137. + previousMode := "异步只审计"
  1138. + if current.Enabled && current.BlockingEnabled {
  1139. + previousMode = "同步阻止"
  1140. + } else if !current.Enabled {
  1141. + previousMode = "关闭审计"
  1142. + }
  1143. + summary := fmt.Sprintf("模式:%s→%s;审计池:%d;类别:%d", previousMode, mode, len(req.Endpoints), len(req.Scanners))
  1144. + if reason != "" {
  1145. + summary += ";原因:" + reason
  1146. + }
  1147. + return summary
  1148. +}
  1149. +
  1150. func normalizeWorkerCount(value int) int {
  1151. if value < 1 {
  1152. return 4
  1153. @@ -709,7 +838,7 @@ func canonicalScannerNameForProtocol(_ string, value string) (string, bool) {
  1154. }
  1155. func normalizeScannerKey(value string) string {
  1156. - return strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(value)))
  1157. + return strings.NewReplacer("_", "", "-", "", " ", "", "&", "and").Replace(strings.ToLower(strings.TrimSpace(value)))
  1158. }
  1159. func hasEnabledEndpointWithScanURL(endpoints []EndpointConfig) bool {
  1160. for _, endpoint := range endpoints {
  1161. @@ -737,6 +866,9 @@ func applyEnvDefaults(cfg *Config) {
  1162. if raw := strings.TrimSpace(os.Getenv("PROMPT_AUDIT_ENABLED")); raw != "" {
  1163. cfg.Enabled = parseEnvBool(raw, cfg.Enabled)
  1164. }
  1165. + if raw := strings.TrimSpace(os.Getenv("PROMPT_AUDIT_BLOCKING_ENABLED")); raw != "" {
  1166. + cfg.BlockingEnabled = cfg.Enabled && parseEnvBool(raw, cfg.BlockingEnabled)
  1167. + }
  1168. if raw := strings.TrimSpace(os.Getenv("PROMPT_AUDIT_STORE_PASS_EVENTS")); raw != "" {
  1169. cfg.StorePassEvents = parseEnvBool(raw, cfg.StorePassEvents)
  1170. }
  1171. diff --git a/ai-gateway/internal/service/promptaudit/config_test.go b/ai-gateway/internal/service/promptaudit/config_test.go
  1172. index b9f766d6b289c7f1eb2ab3a4f7dfb2e3fb32e0c7..b20070937a3c6c18a55ea1ea979902b6c17c0a13 100644
  1173. --- a/ai-gateway/internal/service/promptaudit/config_test.go
  1174. +++ b/ai-gateway/internal/service/promptaudit/config_test.go
  1175. @@ -66,6 +66,7 @@ func clearPromptAuditEnv(t *testing.T) {
  1176. t.Helper()
  1177. for _, key := range []string{
  1178. "PROMPT_AUDIT_ENABLED",
  1179. + "PROMPT_AUDIT_BLOCKING_ENABLED",
  1180. "PROMPT_AUDIT_STORE_PASS_EVENTS",
  1181. "PROMPT_AUDIT_STRATEGY",
  1182. "PROMPT_AUDIT_WORKER_COUNT",
  1183. @@ -153,8 +154,8 @@ func TestConfigServiceLoadsHotOptionMapWithoutDatabaseAndMasksToken(t *testing.T
  1184. if err != nil {
  1185. t.Fatalf("Load() should use common.OptionMap without touching DB: %v", err)
  1186. }
  1187. - if !cfg.Enabled || cfg.Strategy != "weighted" || cfg.AuditGroupMode != AuditGroupModeSelected {
  1188. - t.Fatalf("hot config not preserved: %+v", cfg)
  1189. + if !cfg.Enabled || cfg.Strategy != "priority" || cfg.AuditGroupMode != AuditGroupModeSelected {
  1190. + t.Fatalf("历史调度策略应迁移为 priority: %+v", cfg)
  1191. }
  1192. if len(cfg.Endpoints) != 1 {
  1193. t.Fatalf("expected one hot endpoint, got %#v", cfg.Endpoints)
  1194. @@ -272,6 +273,7 @@ func TestConfigServiceSaveRejectsInvalidEndpointAndCanClearExistingToken(t *test
  1195. func TestDefaultConfigAppliesPromptAuditEnvironmentDefaults(t *testing.T) {
  1196. clearPromptAuditEnv(t)
  1197. t.Setenv("PROMPT_AUDIT_ENABLED", "true")
  1198. + t.Setenv("PROMPT_AUDIT_BLOCKING_ENABLED", "true")
  1199. t.Setenv("PROMPT_AUDIT_STORE_PASS_EVENTS", "true")
  1200. t.Setenv("PROMPT_AUDIT_STRATEGY", "weighted")
  1201. t.Setenv("PROMPT_AUDIT_WORKER_COUNT", "2")
  1202. @@ -283,7 +285,7 @@ func TestDefaultConfigAppliesPromptAuditEnvironmentDefaults(t *testing.T) {
  1203. cfg := DefaultConfig()
  1204. - if !cfg.Enabled || !cfg.StorePassEvents || cfg.Strategy != "weighted" {
  1205. + if !cfg.Enabled || !cfg.BlockingEnabled || !cfg.StorePassEvents || cfg.Strategy != "priority" {
  1206. t.Fatalf("env flags not applied: %+v", cfg)
  1207. }
  1208. if cfg.WorkerCount != 2 || cfg.QueueCapacity != 250 {
  1209. @@ -508,7 +510,7 @@ func TestConfigServiceSaveEncryptsTokenAndPublicResponseMasksToken(t *testing.T)
  1210. publicCfg, err := svc.Save(context.Background(), SaveConfigRequest{
  1211. Enabled: true,
  1212. - Strategy: "weighted",
  1213. + Strategy: "priority",
  1214. WorkerCount: 96,
  1215. QueueCapacity: 100000,
  1216. Scanners: []string{"Jailbreak", "Jailbreak", "PII"},
  1217. @@ -662,7 +664,7 @@ func TestDefaultConfigAppliesPromptAuditEnvOverrides(t *testing.T) {
  1218. cfg := DefaultConfig()
  1219. - if !cfg.Enabled || !cfg.StorePassEvents || cfg.Strategy != "shadow" {
  1220. + if !cfg.Enabled || !cfg.StorePassEvents || cfg.Strategy != "priority" {
  1221. t.Fatalf("boolean/strategy env not applied: %#v", cfg)
  1222. }
  1223. if cfg.WorkerCount != 9 || cfg.QueueCapacity != 12345 {
  1224. diff --git a/ai-gateway/internal/service/promptaudit/diagnostics_test.go b/ai-gateway/internal/service/promptaudit/diagnostics_test.go
  1225. index bccee339b8f580fa3a15ba59d8dde8062f080d10..078c01f03d2a71e8ebb861a65cab666211eb1d62 100644
  1226. --- a/ai-gateway/internal/service/promptaudit/diagnostics_test.go
  1227. +++ b/ai-gateway/internal/service/promptaudit/diagnostics_test.go
  1228. @@ -146,7 +146,7 @@ func TestPromptAuditSmallHelpersExposeOperatorSafeDefaults(t *testing.T) {
  1229. if promptAuditProbeErrorMessage(nil) != "" {
  1230. t.Fatal("nil probe error message should stay empty")
  1231. }
  1232. - if got := promptAuditProbeErrorMessage(newLLMGuardError("openai_guard_ready_failed", "", true, 503)); !strings.Contains(got, "openai_guard_ready_failed") || !strings.Contains(got, "status=503") {
  1233. - t.Fatalf("probe error message should expose stable code and status when message is empty, got %q", got)
  1234. + if got := promptAuditProbeErrorMessage(newLLMGuardError("openai_guard_ready_failed", "", true, 503)); got != "Guard 探测失败" {
  1235. + t.Fatalf("probe error message should remain generic and not expose upstream detail, got %q", got)
  1236. }
  1237. }
  1238. diff --git a/ai-gateway/internal/service/promptaudit/enqueue.go b/ai-gateway/internal/service/promptaudit/enqueue.go
  1239. index 6ac5329666fa875ac1984d7402f28afd4a72de3c..df92920a9763c3afff208d6efedc16a872764d0c 100644
  1240. --- a/ai-gateway/internal/service/promptaudit/enqueue.go
  1241. +++ b/ai-gateway/internal/service/promptaudit/enqueue.go
  1242. @@ -15,7 +15,10 @@ import (
  1243. "github.com/mt21625457/aicodex/internal/types"
  1244. )
  1245. -const promptAuditEnqueueAttemptedKey = "prompt_audit_enqueue_attempted"
  1246. +const (
  1247. + promptAuditEnqueueAttemptedKey = "prompt_audit_enqueue_attempted"
  1248. + promptAuditConfigCacheTTL = 5 * time.Second
  1249. +)
  1250. var (
  1251. defaultMemoryPayloadStore = NewMemoryPayloadStore()
  1252. @@ -24,8 +27,13 @@ var (
  1253. configCacheMu sync.Mutex
  1254. configCacheValue Config
  1255. configCacheLoadedAt time.Time
  1256. + configCacheRefreshAfter time.Time
  1257. + configCacheLastError string
  1258. + configCacheLastErrorAt time.Time
  1259. )
  1260. +const promptGuardConfigInvalidationChannel = "aicodex:prompt_guard:config:invalidate"
  1261. +
  1262. func SetDefaultsForTesting(repo JobRepository, payloadStore PayloadStore) func() {
  1263. previousRepo := defaultRepository
  1264. previousPayload := defaultPayloadStore
  1265. @@ -48,10 +56,130 @@ func ConfigureDefaultPayloadStore(payloadStore PayloadStore) {
  1266. func ClearConfigCache() {
  1267. configCacheMu.Lock()
  1268. configCacheLoadedAt = time.Time{}
  1269. + configCacheRefreshAfter = time.Time{}
  1270. configCacheValue = Config{}
  1271. + configCacheLastError = ""
  1272. + configCacheLastErrorAt = time.Time{}
  1273. configCacheMu.Unlock()
  1274. }
  1275. +func invalidateConfigCache() {
  1276. + configCacheMu.Lock()
  1277. + configCacheRefreshAfter = time.Time{}
  1278. + configCacheMu.Unlock()
  1279. +}
  1280. +
  1281. +func installConfigSnapshot(cfg Config) Config {
  1282. + now := time.Now()
  1283. + configCacheMu.Lock()
  1284. + if configCacheValue.ConfigVersion > 0 && normalizeConfigVersion(cfg.ConfigVersion) < normalizeConfigVersion(configCacheValue.ConfigVersion) {
  1285. + current := configCacheValue
  1286. + configCacheMu.Unlock()
  1287. + return current
  1288. + }
  1289. + configCacheValue = cfg
  1290. + configCacheLoadedAt = now
  1291. + configCacheRefreshAfter = now.Add(promptAuditConfigCacheTTL)
  1292. + configCacheLastError = ""
  1293. + configCacheLastErrorAt = time.Time{}
  1294. + configCacheMu.Unlock()
  1295. + LogInfoEvent(
  1296. + "prompt_guard.config_loaded",
  1297. + Field("status", "success"),
  1298. + Field("config_version", normalizeConfigVersion(cfg.ConfigVersion)),
  1299. + Field("blocking_enabled", cfg.Enabled && cfg.BlockingEnabled),
  1300. + )
  1301. + return cfg
  1302. +}
  1303. +
  1304. +func publishConfigInvalidation(ctx context.Context, configVersion int64) {
  1305. + if !common.RedisEnabled || common.RDB == nil {
  1306. + LogWarnEvent(
  1307. + "prompt_guard.config_reload_degraded",
  1308. + Field("status", "degraded"),
  1309. + Field("error_code", "redis_unavailable"),
  1310. + Field("config_version", normalizeConfigVersion(configVersion)),
  1311. + )
  1312. + return
  1313. + }
  1314. + if err := common.RDB.Publish(ctx, promptGuardConfigInvalidationChannel, fmt.Sprintf("%d", normalizeConfigVersion(configVersion))).Err(); err != nil {
  1315. + LogWarnEvent(
  1316. + "prompt_guard.config_reload_degraded",
  1317. + Field("status", "degraded"),
  1318. + Field("error_code", "config_invalidation_publish_failed"),
  1319. + Field("config_version", normalizeConfigVersion(configVersion)),
  1320. + Field("error_kind", "redis_publish_failed"),
  1321. + )
  1322. + }
  1323. +}
  1324. +
  1325. +// StartConfigInvalidationSubscriber 监听多实例配置失效通知;Redis 不可用时继续使用 5 秒 TTL。
  1326. +func StartConfigInvalidationSubscriber(ctx context.Context) {
  1327. + if !common.RedisEnabled || common.RDB == nil {
  1328. + return
  1329. + }
  1330. + go func() {
  1331. + pubsub := common.RDB.Subscribe(ctx, promptGuardConfigInvalidationChannel)
  1332. + defer pubsub.Close()
  1333. + if _, err := pubsub.Receive(ctx); err != nil {
  1334. + LogWarnEvent(
  1335. + "prompt_guard.config_reload_degraded",
  1336. + Field("status", "degraded"),
  1337. + Field("error_code", "config_invalidation_subscribe_failed"),
  1338. + Field("error_kind", "redis_subscribe_failed"),
  1339. + )
  1340. + return
  1341. + }
  1342. + channel := pubsub.Channel()
  1343. + for {
  1344. + select {
  1345. + case <-ctx.Done():
  1346. + return
  1347. + case _, ok := <-channel:
  1348. + if !ok {
  1349. + LogWarnEvent(
  1350. + "prompt_guard.config_reload_degraded",
  1351. + Field("status", "degraded"),
  1352. + Field("error_code", "config_invalidation_channel_closed"),
  1353. + Field("error_kind", "redis_subscription_closed"),
  1354. + )
  1355. + return
  1356. + }
  1357. + invalidateConfigCache()
  1358. + if cfg, err := NewConfigService(nil).Load(ctx); err == nil {
  1359. + installConfigSnapshot(cfg)
  1360. + } else {
  1361. + recordConfigLoadError(err)
  1362. + }
  1363. + }
  1364. + }
  1365. + }()
  1366. +}
  1367. +
  1368. +func recordConfigLoadError(err error) {
  1369. + if err == nil {
  1370. + return
  1371. + }
  1372. + configCacheMu.Lock()
  1373. + configCacheLastError = "提示词审计配置加载失败"
  1374. + configCacheLastErrorAt = time.Now().UTC()
  1375. + lastVersion := configCacheValue.ConfigVersion
  1376. + configCacheMu.Unlock()
  1377. + LogWarnEvent(
  1378. + "prompt_guard.config_reload_degraded",
  1379. + Field("status", "degraded"),
  1380. + Field("error_code", "config_load_failed"),
  1381. + Field("config_version", normalizeConfigVersion(lastVersion)),
  1382. + Field("error_kind", "config_load_failed"),
  1383. + )
  1384. +}
  1385. +
  1386. +func configLoadRuntimeState() (Config, time.Time, string, time.Time) {
  1387. + configCacheMu.Lock()
  1388. + defer configCacheMu.Unlock()
  1389. + return configCacheValue, configCacheLoadedAt, configCacheLastError, configCacheLastErrorAt
  1390. +}
  1391. +
  1392. func MaybeEnqueueFromGateway(c *gin.Context, relayFormat types.RelayFormat, requestBody []byte) {
  1393. maybeEnqueueFromGateway(c, relayFormat, requestBody, false)
  1394. }
  1395. @@ -121,13 +249,16 @@ func EnqueueFromBody(ctx context.Context, relayFormat types.RelayFormat, path st
  1396. Field("reason", "config_load_failed"),
  1397. Field("request_id", snapshotContext.RequestID),
  1398. Field("error_code", "config_load_failed"),
  1399. - Field("error_kind", err.Error()),
  1400. + Field("error_kind", "config_load_failed"),
  1401. )
  1402. return false, err
  1403. }
  1404. if !cfg.Enabled {
  1405. return false, nil
  1406. }
  1407. + if cfg.BlockingEnabled {
  1408. + return false, nil
  1409. + }
  1410. if ok, reason := cfg.ShouldAuditGroup(snapshotContext.Group); !ok {
  1411. LogWarnEvent(
  1412. "prompt_audit.enqueue_dropped",
  1413. @@ -380,18 +511,31 @@ func hasTopLevelJSONKey(body []byte, key string) bool {
  1414. }
  1415. func loadCachedConfig(ctx context.Context) (Config, error) {
  1416. + now := time.Now()
  1417. configCacheMu.Lock()
  1418. - defer configCacheMu.Unlock()
  1419. - if !configCacheLoadedAt.IsZero() && time.Since(configCacheLoadedAt) < 5*time.Second {
  1420. - return configCacheValue, nil
  1421. + if configCacheValue.ConfigVersion > 0 && now.Before(configCacheRefreshAfter) {
  1422. + cfg := configCacheValue
  1423. + configCacheMu.Unlock()
  1424. + return cfg, nil
  1425. }
  1426. + lastValid := configCacheValue
  1427. + hasLastValid := !configCacheLoadedAt.IsZero() || lastValid.ConfigVersion > 0
  1428. + configCacheMu.Unlock()
  1429. cfg, err := NewConfigService(nil).Load(ctx)
  1430. if err != nil {
  1431. + recordConfigLoadError(err)
  1432. + if hasLastValid {
  1433. + configCacheMu.Lock()
  1434. + if configCacheValue.ConfigVersion == lastValid.ConfigVersion {
  1435. + configCacheRefreshAfter = now.Add(promptAuditConfigCacheTTL)
  1436. + lastValid = configCacheValue
  1437. + }
  1438. + configCacheMu.Unlock()
  1439. + return lastValid, nil
  1440. + }
  1441. return Config{}, err
  1442. }
  1443. - configCacheValue = cfg
  1444. - configCacheLoadedAt = time.Now()
  1445. - return cfg, nil
  1446. + return installConfigSnapshot(cfg), nil
  1447. }
  1448. func enqueueError(code string, format string, args ...any) error {
  1449. diff --git a/ai-gateway/internal/service/promptaudit/openai_client.go b/ai-gateway/internal/service/promptaudit/openai_client.go
  1450. index c429064815c47c69a88b769828ef1046343bddfc..aa3f94f029dd54b12e33d608c6234136d993dcdf 100644
  1451. --- a/ai-gateway/internal/service/promptaudit/openai_client.go
  1452. +++ b/ai-gateway/internal/service/promptaudit/openai_client.go
  1453. @@ -19,7 +19,7 @@ type OpenAICompatibleClient struct {
  1454. func NewOpenAICompatibleClient(httpClient *http.Client) *OpenAICompatibleClient {
  1455. if httpClient == nil {
  1456. - httpClient = &http.Client{}
  1457. + httpClient = newSecureGuardHTTPClient()
  1458. }
  1459. return &OpenAICompatibleClient{httpClient: httpClient}
  1460. }
  1461. @@ -90,6 +90,9 @@ func (c *OpenAICompatibleClient) ScanPrompt(ctx context.Context, endpoint Endpoi
  1462. Field("latency_ms", result.LatencyMS),
  1463. )
  1464. LogInfoEvent("prompt_audit.scan_chunk_completed", fields...)
  1465. + if scanCtx.StopOnBlock && result.Action == "Block" {
  1466. + break
  1467. + }
  1468. }
  1469. prependAggregatedGuardPolicy(&aggregated, len(chunks), inputChars, inputLimit)
  1470. LogInfoEvent(
  1471. @@ -118,6 +121,9 @@ func (c *OpenAICompatibleClient) scanPromptChunk(ctx context.Context, endpoint E
  1472. if chatURL == "" {
  1473. return LLMGuardScanResult{}, newLLMGuardError("openai_guard_not_configured", "OpenAI 兼容审计 Base URL 为空", false, 0)
  1474. }
  1475. + if err := validateGuardBaseURL(firstNonEmptyString(endpoint.BaseURL, endpoint.ScanURL)); err != nil {
  1476. + return LLMGuardScanResult{}, newLLMGuardError("openai_guard_endpoint_denied", "Guard Base URL 未通过安全校验", false, 0)
  1477. + }
  1478. model := normalizeGuardModel(ProtocolOpenAICompatible, endpoint.Model)
  1479. timeout := time.Duration(endpoint.TimeoutMS) * time.Millisecond
  1480. if timeout <= 0 {
  1481. @@ -141,7 +147,7 @@ func (c *OpenAICompatibleClient) scanPromptChunk(ctx context.Context, endpoint E
  1482. }
  1483. req, err := http.NewRequestWithContext(callCtx, http.MethodPost, chatURL, bytes.NewReader(body))
  1484. if err != nil {
  1485. - return LLMGuardScanResult{}, err
  1486. + return LLMGuardScanResult{}, newLLMGuardError("openai_guard_request_invalid", "创建 Guard 请求失败", false, 0)
  1487. }
  1488. req.Header.Set("Content-Type", "application/json")
  1489. if token := strings.TrimSpace(endpoint.Token); token != "" {
  1490. @@ -155,7 +161,7 @@ func (c *OpenAICompatibleClient) scanPromptChunk(ctx context.Context, endpoint E
  1491. if errors.Is(callCtx.Err(), context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout") {
  1492. return LLMGuardScanResult{}, newLLMGuardError("openai_guard_timeout", "OpenAI 兼容审计调用超时", true, 0)
  1493. }
  1494. - return LLMGuardScanResult{}, newLLMGuardError("openai_guard_request_failed", err.Error(), true, 0)
  1495. + return LLMGuardScanResult{}, newLLMGuardError("openai_guard_request_failed", "OpenAI 兼容审计请求失败", true, 0)
  1496. }
  1497. defer resp.Body.Close()
  1498. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  1499. @@ -169,8 +175,15 @@ func (c *OpenAICompatibleClient) scanPromptChunk(ctx context.Context, endpoint E
  1500. return LLMGuardScanResult{}, newLLMGuardError(code, message, retryable, resp.StatusCode)
  1501. }
  1502. + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxGuardResponseBytes+1))
  1503. + if err != nil {
  1504. + return LLMGuardScanResult{}, newLLMGuardError("openai_guard_invalid_response", "读取 Guard 响应失败", false, resp.StatusCode)
  1505. + }
  1506. + if int64(len(responseBody)) > maxGuardResponseBytes {
  1507. + return LLMGuardScanResult{}, newLLMGuardError("openai_guard_invalid_response", "Guard 响应超过大小上限", false, resp.StatusCode)
  1508. + }
  1509. var payload map[string]any
  1510. - if err := json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&payload); err != nil {
  1511. + if err := json.Unmarshal(responseBody, &payload); err != nil {
  1512. return LLMGuardScanResult{}, newLLMGuardError("openai_guard_invalid_response", err.Error(), false, resp.StatusCode)
  1513. }
  1514. content := extractOpenAIChatContent(payload)
  1515. @@ -183,6 +196,9 @@ func (c *OpenAICompatibleClient) scanPromptChunk(ctx context.Context, endpoint E
  1516. if len(scanners) == 0 {
  1517. enabledCategories = parsed.Categories
  1518. }
  1519. + if parsed.Safety == SafetyUnsafe && parsed.HasUnknownCategory {
  1520. + enabledCategories = append(enabledCategories, "unknown_unsafe")
  1521. + }
  1522. result := buildQwen3GuardScanResult(parsed, enabledCategories, model, latencyMS)
  1523. return result, nil
  1524. }
  1525. @@ -324,6 +340,9 @@ func (c *OpenAICompatibleClient) CheckReady(ctx context.Context, endpoint Endpoi
  1526. if base == "" {
  1527. return newLLMGuardError("openai_guard_not_configured", "OpenAI 兼容审计 Base URL 为空", false, 0)
  1528. }
  1529. + if err := validateGuardBaseURL(base); err != nil {
  1530. + return newLLMGuardError("openai_guard_endpoint_denied", "Guard Base URL 未通过安全校验", false, 0)
  1531. + }
  1532. if err := c.checkModels(ctx, endpoint, base); err == nil {
  1533. return nil
  1534. } else if !shouldFallbackOpenAIReadyCheck(err) {
  1535. @@ -342,11 +361,11 @@ func (c *OpenAICompatibleClient) checkModels(ctx context.Context, endpoint Endpo
  1536. defer cancel()
  1537. modelsURL, err := url.JoinPath(base, "/v1/models")
  1538. if err != nil {
  1539. - return err
  1540. + return newLLMGuardError("openai_guard_request_invalid", "创建 Guard 探测地址失败", false, 0)
  1541. }
  1542. req, err := http.NewRequestWithContext(callCtx, http.MethodGet, modelsURL, nil)
  1543. if err != nil {
  1544. - return err
  1545. + return newLLMGuardError("openai_guard_request_invalid", "创建 Guard 探测请求失败", false, 0)
  1546. }
  1547. if token := strings.TrimSpace(endpoint.Token); token != "" {
  1548. req.Header.Set("Authorization", "Bearer "+token)
  1549. @@ -356,7 +375,7 @@ func (c *OpenAICompatibleClient) checkModels(ctx context.Context, endpoint Endpo
  1550. if errors.Is(callCtx.Err(), context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout") {
  1551. return newLLMGuardError("openai_guard_timeout", "OpenAI 兼容 /v1/models 超时", true, 0)
  1552. }
  1553. - return newLLMGuardError("openai_guard_request_failed", err.Error(), true, 0)
  1554. + return newLLMGuardError("openai_guard_request_failed", "OpenAI 兼容 Guard 探测请求失败", true, 0)
  1555. }
  1556. defer resp.Body.Close()
  1557. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  1558. @@ -384,7 +403,11 @@ func buildQwen3GuardScanResult(parsed qwen3GuardParsed, categories []string, mod
  1559. isValid := true
  1560. switch safety {
  1561. case SafetyUnsafe:
  1562. - action = "Block"
  1563. + if len(categories) > 0 || len(parsed.Categories) == 0 || parsed.HasUnknownCategory {
  1564. + action = "Block"
  1565. + } else {
  1566. + action = "Warn"
  1567. + }
  1568. isValid = false
  1569. case SafetyControversial:
  1570. action = "Warn"
  1571. @@ -415,14 +438,26 @@ func buildQwen3GuardScanResult(parsed qwen3GuardParsed, categories []string, mod
  1572. if len(findings) > 0 {
  1573. evidence["_guard_findings"] = findings
  1574. }
  1575. + observedFindings := make([]ScannerEvidenceItem, 0, len(parsed.Categories))
  1576. + for _, category := range parsed.Categories {
  1577. + observedFindings = append(observedFindings, ScannerEvidenceItem{
  1578. + ScannerID: category,
  1579. + Category: category,
  1580. + Kind: "classification",
  1581. + Severity: safety,
  1582. + })
  1583. + }
  1584. + if len(observedFindings) > 0 {
  1585. + evidence["_guard_observed_categories"] = observedFindings
  1586. + }
  1587. evidence["_guard_policy"] = []ScannerEvidenceItem{{
  1588. Kind: "policy",
  1589. Summary: fmt.Sprintf("action=%s safety=%s model=%s", action, safety, model),
  1590. Metadata: map[string]any{
  1591. - "safety": safety,
  1592. - "categories": categories,
  1593. - "model": model,
  1594. - "raw": trimForDB(parsed.Raw, 512),
  1595. + "safety": safety,
  1596. + "observed_categories": append([]string(nil), parsed.Categories...),
  1597. + "enforced_categories": append([]string(nil), categories...),
  1598. + "model": model,
  1599. },
  1600. }}
  1601. diff --git a/ai-gateway/internal/service/promptaudit/probe.go b/ai-gateway/internal/service/promptaudit/probe.go
  1602. index 95d6e8db20a2cb194a0e35f701d1548a1d52709b..d7439473508570da5bdd0c049eb2e8737df5c82e 100644
  1603. --- a/ai-gateway/internal/service/promptaudit/probe.go
  1604. +++ b/ai-gateway/internal/service/promptaudit/probe.go
  1605. @@ -82,13 +82,24 @@ func promptAuditProbeErrorCode(err error) string {
  1606. }
  1607. func promptAuditProbeErrorMessage(err error) string {
  1608. - if llmErr := llmGuardErrorFrom(err); llmErr != nil && strings.TrimSpace(llmErr.Message) != "" {
  1609. - return llmErr.Message
  1610. + if llmErr := llmGuardErrorFrom(err); llmErr != nil {
  1611. + switch llmErr.Code {
  1612. + case "openai_guard_auth_failed":
  1613. + return "Guard 认证失败"
  1614. + case "openai_guard_timeout":
  1615. + return "Guard 探测超时"
  1616. + case "openai_guard_invalid_response":
  1617. + return "Guard 返回了非法响应"
  1618. + case "openai_guard_endpoint_denied":
  1619. + return "Guard 地址未通过安全校验"
  1620. + default:
  1621. + return "Guard 探测失败"
  1622. + }
  1623. }
  1624. if err == nil {
  1625. return ""
  1626. }
  1627. - return err.Error()
  1628. + return "Guard 探测失败"
  1629. }
  1630. func llmGuardErrorFrom(err error) *LLMGuardError {
  1631. diff --git a/ai-gateway/internal/service/promptaudit/probe_test.go b/ai-gateway/internal/service/promptaudit/probe_test.go
  1632. index a529981f7f9108b8a86ee4aa1d67e90f8fb54786..4eae84d8c78c7c2a39987e5ecdcb456d0358e23e 100644
  1633. --- a/ai-gateway/internal/service/promptaudit/probe_test.go
  1634. +++ b/ai-gateway/internal/service/promptaudit/probe_test.go
  1635. @@ -100,7 +100,7 @@ func TestProbeEndpointReturnsStableGuardErrorFields(t *testing.T) {
  1636. TimeoutMS: 1000,
  1637. })
  1638. - if result.OK || result.Status != "error" || result.ErrorCode != "openai_guard_auth_failed" || result.Message != "bad api key" {
  1639. + if result.OK || result.Status != "error" || result.ErrorCode != "openai_guard_auth_failed" || result.Message != "Guard 认证失败" {
  1640. t.Fatalf("unexpected probe result: %#v", result)
  1641. }
  1642. if result.HTTPStatus != http.StatusUnauthorized || result.Retryable {
  1643. @@ -118,7 +118,7 @@ func TestProbeEndpointReturnsGenericProbeFailureForUnknownError(t *testing.T) {
  1644. TimeoutMS: 1000,
  1645. })
  1646. - if result.OK || result.ErrorCode != "prompt_audit_probe_failed" || result.Message != "network unavailable" {
  1647. + if result.OK || result.ErrorCode != "prompt_audit_probe_failed" || result.Message != "Guard 探测失败" {
  1648. t.Fatalf("unexpected generic probe failure: %#v", result)
  1649. }
  1650. }
  1651. diff --git a/ai-gateway/internal/service/promptaudit/qwen3guard.go b/ai-gateway/internal/service/promptaudit/qwen3guard.go
  1652. index d510b9afd47822f845de7205ed0a1b97fadfed2e..cc8dbc006642e2d95d84273f51f73ab69d885b84 100644
  1653. --- a/ai-gateway/internal/service/promptaudit/qwen3guard.go
  1654. +++ b/ai-gateway/internal/service/promptaudit/qwen3guard.go
  1655. @@ -5,11 +5,11 @@ import (
  1656. "strings"
  1657. )
  1658. - const (
  1659. - ProtocolOpenAICompatible = "openai_compatible"
  1660. -
  1661. - DefaultQwen3GuardModel = "sileader/qwen3guard:0.6b"
  1662. - ScannerBackendQwen3Guard = "qwen3guard-openai"
  1663. +const (
  1664. + ProtocolOpenAICompatible = "openai_compatible"
  1665. +
  1666. + DefaultQwen3GuardModel = "sileader/qwen3guard:0.6b"
  1667. + ScannerBackendQwen3Guard = "qwen3guard-openai"
  1668. SafetySafe = "Safe"
  1669. SafetyControversial = "Controversial"
  1670. @@ -42,25 +42,26 @@ func defaultOpenAICompatibleScanners() []string {
  1671. return Qwen3GuardCategoryCatalog()
  1672. }
  1673. - func normalizeProtocol(value string) string {
  1674. - // 提示词审计仅支持 OpenAI 兼容;历史 llm_guard / 空值一律归一。
  1675. - _ = value
  1676. - return ProtocolOpenAICompatible
  1677. - }
  1678. -
  1679. - func normalizeGuardModel(_ string, value string) string {
  1680. - value = strings.TrimSpace(value)
  1681. - if value == "" {
  1682. - return DefaultQwen3GuardModel
  1683. - }
  1684. - return value
  1685. +func normalizeProtocol(value string) string {
  1686. + // 提示词审计仅支持 OpenAI 兼容;历史 llm_guard / 空值一律归一。
  1687. + _ = value
  1688. + return ProtocolOpenAICompatible
  1689. +}
  1690. +
  1691. +func normalizeGuardModel(_ string, value string) string {
  1692. + value = strings.TrimSpace(value)
  1693. + if value == "" {
  1694. + return DefaultQwen3GuardModel
  1695. }
  1696. + return value
  1697. +}
  1698. type qwen3GuardParsed struct {
  1699. - Safety string
  1700. - Categories []string
  1701. - Raw string
  1702. - Valid bool
  1703. + Safety string
  1704. + Categories []string
  1705. + HasUnknownCategory bool
  1706. + Raw string
  1707. + Valid bool
  1708. }
  1709. func parseQwen3GuardOutput(content string) qwen3GuardParsed {
  1710. @@ -69,16 +70,46 @@ func parseQwen3GuardOutput(content string) qwen3GuardParsed {
  1711. if content == "" {
  1712. return parsed
  1713. }
  1714. - if match := safetyLineRegexp.FindStringSubmatch(content); len(match) == 2 {
  1715. - parsed.Safety = canonicalizeSafety(match[1])
  1716. + nonEmptyLines := make([]string, 0, 2)
  1717. + for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
  1718. + if strings.TrimSpace(line) != "" {
  1719. + nonEmptyLines = append(nonEmptyLines, line)
  1720. + }
  1721. }
  1722. - if match := categoriesLineRegexp.FindStringSubmatch(content); len(match) == 2 {
  1723. - parsed.Categories = splitCategories(match[1])
  1724. + if len(nonEmptyLines) != 2 {
  1725. + return parsed
  1726. }
  1727. - parsed.Valid = parsed.Safety != ""
  1728. + safetyMatches := safetyLineRegexp.FindAllStringSubmatch(content, -1)
  1729. + categoryMatches := categoriesLineRegexp.FindAllStringSubmatch(content, -1)
  1730. + if len(safetyMatches) != 1 || len(categoryMatches) != 1 {
  1731. + return parsed
  1732. + }
  1733. + switch strings.TrimSpace(safetyMatches[0][1]) {
  1734. + case SafetySafe, SafetyControversial, SafetyUnsafe:
  1735. + parsed.Safety = strings.TrimSpace(safetyMatches[0][1])
  1736. + default:
  1737. + return parsed
  1738. + }
  1739. + parsed.Categories = splitCategories(categoryMatches[0][1])
  1740. + for _, category := range parsed.Categories {
  1741. + if !isKnownQwen3GuardCategory(category) {
  1742. + parsed.HasUnknownCategory = true
  1743. + }
  1744. + }
  1745. + parsed.Valid = true
  1746. return parsed
  1747. }
  1748. +func isKnownQwen3GuardCategory(category string) bool {
  1749. + key := normalizeScannerKey(category)
  1750. + for _, known := range qwen3GuardCategoryCatalog {
  1751. + if normalizeScannerKey(known) == key {
  1752. + return true
  1753. + }
  1754. + }
  1755. + return false
  1756. +}
  1757. +
  1758. func canonicalizeSafety(value string) string {
  1759. switch strings.ToLower(strings.TrimSpace(value)) {
  1760. case "safe":
  1761. @@ -128,29 +159,29 @@ func canonicalizeQwen3GuardCategory(value string) string {
  1762. return item
  1763. }
  1764. }
  1765. - aliases := map[string]string{
  1766. - "violence": "Violent",
  1767. - "violentcontent": "Violent",
  1768. - "nonviolentillegalacts": "Non-violent Illegal Acts",
  1769. - "illegal": "Non-violent Illegal Acts",
  1770. - "sexual": "Sexual Content or Sexual Acts",
  1771. - "sexualcontent": "Sexual Content or Sexual Acts",
  1772. - "sexualcontentorsexualacts": "Sexual Content or Sexual Acts",
  1773. - "selfharm": "Suicide & Self-Harm",
  1774. - "suicide": "Suicide & Self-Harm",
  1775. - "suicideandselfharm": "Suicide & Self-Harm",
  1776. - "unethical": "Unethical Acts",
  1777. - "hate": "Unethical Acts",
  1778. - "political": "Politically Sensitive Topics",
  1779. - "politicallysensitive": "Politically Sensitive Topics",
  1780. - "politicallysensitivetopics": "Politically Sensitive Topics",
  1781. - "copyright": "Copyright Violation",
  1782. - "copyrightviolation": "Copyright Violation",
  1783. - "promptinjection": "Jailbreak",
  1784. - "llamapromptguard2": "Jailbreak",
  1785. - "injection": "Jailbreak",
  1786. - "secrets": "PII",
  1787. - }
  1788. + aliases := map[string]string{
  1789. + "violence": "Violent",
  1790. + "violentcontent": "Violent",
  1791. + "nonviolentillegalacts": "Non-violent Illegal Acts",
  1792. + "illegal": "Non-violent Illegal Acts",
  1793. + "sexual": "Sexual Content or Sexual Acts",
  1794. + "sexualcontent": "Sexual Content or Sexual Acts",
  1795. + "sexualcontentorsexualacts": "Sexual Content or Sexual Acts",
  1796. + "selfharm": "Suicide & Self-Harm",
  1797. + "suicide": "Suicide & Self-Harm",
  1798. + "suicideandselfharm": "Suicide & Self-Harm",
  1799. + "unethical": "Unethical Acts",
  1800. + "hate": "Unethical Acts",
  1801. + "political": "Politically Sensitive Topics",
  1802. + "politicallysensitive": "Politically Sensitive Topics",
  1803. + "politicallysensitivetopics": "Politically Sensitive Topics",
  1804. + "copyright": "Copyright Violation",
  1805. + "copyrightviolation": "Copyright Violation",
  1806. + "promptinjection": "Jailbreak",
  1807. + "llamapromptguard2": "Jailbreak",
  1808. + "injection": "Jailbreak",
  1809. + "secrets": "PII",
  1810. + }
  1811. if mapped, ok := aliases[key]; ok {
  1812. return mapped
  1813. }
  1814. diff --git a/ai-gateway/internal/service/promptaudit/runtime.go b/ai-gateway/internal/service/promptaudit/runtime.go
  1815. index befb3463c092e42230530574ff38f4e5f04b1854..a3f9959d3c2ba17e8a0adb71a83139de6f2b57a8 100644
  1816. --- a/ai-gateway/internal/service/promptaudit/runtime.go
  1817. +++ b/ai-gateway/internal/service/promptaudit/runtime.go
  1818. @@ -33,21 +33,46 @@ func Runtime(ctx context.Context, repo JobRepository, configSvc *ConfigService)
  1819. if configSvc == nil {
  1820. configSvc = NewConfigService(nil)
  1821. }
  1822. - cfg, err := configSvc.Public(ctx)
  1823. - if err != nil {
  1824. - cfg = DefaultConfig().Public()
  1825. + cfg, configLoadErr := configSvc.Public(ctx)
  1826. + if configLoadErr != nil {
  1827. + recordConfigLoadError(configLoadErr)
  1828. + activeCfg, _, _, _ := configLoadRuntimeState()
  1829. + if activeCfg.ConfigVersion > 0 {
  1830. + cfg = activeCfg.Public()
  1831. + } else {
  1832. + cfg = DefaultConfig().Public()
  1833. + }
  1834. }
  1835. snapshot := RuntimeSnapshot{
  1836. - Enabled: cfg.Enabled,
  1837. - ProcessStatus: "not_started",
  1838. - QueueCapacity: cfg.QueueCapacity,
  1839. - WorkerTotal: cfg.WorkerCount,
  1840. - LLMGuardConnectivity: publicConfigConnectivity(cfg),
  1841. - StorageSupported: repo.StorageSupported(),
  1842. - Config: cfg,
  1843. - QueueBackend: queueBackendName(repo),
  1844. - PayloadStore: payloadStoreName(defaultPayloadStore),
  1845. - PayloadStoreDegraded: payloadStoreDegraded(defaultPayloadStore),
  1846. + Enabled: cfg.Enabled,
  1847. + BlockingEnabled: cfg.Enabled && cfg.BlockingEnabled,
  1848. + EffectiveMode: effectivePromptAuditMode(cfg.Enabled, cfg.BlockingEnabled),
  1849. + ExpectedConfigVersion: normalizeConfigVersion(cfg.ConfigVersion),
  1850. + ProcessStatus: "not_started",
  1851. + QueueCapacity: cfg.QueueCapacity,
  1852. + WorkerTotal: cfg.WorkerCount,
  1853. + LLMGuardConnectivity: publicConfigConnectivity(cfg),
  1854. + StorageSupported: repo.StorageSupported(),
  1855. + Config: cfg,
  1856. + PromptGuardMetrics: GetPromptGuardMetricsSnapshot(),
  1857. + QueueBackend: queueBackendName(repo),
  1858. + PayloadStore: payloadStoreName(defaultPayloadStore),
  1859. + PayloadStoreDegraded: payloadStoreDegraded(defaultPayloadStore),
  1860. + }
  1861. + activeCfg, loadedAt, loadErr, loadErrAt := configLoadRuntimeState()
  1862. + if activeCfg.ConfigVersion > 0 {
  1863. + snapshot.ActiveConfigVersion = normalizeConfigVersion(activeCfg.ConfigVersion)
  1864. + }
  1865. + if !loadedAt.IsZero() {
  1866. + loadedAt = loadedAt.UTC()
  1867. + snapshot.ConfigLoadedAt = &loadedAt
  1868. + }
  1869. + if loadErr != "" {
  1870. + snapshot.ConfigLoadError = loadErr
  1871. + if !loadErrAt.IsZero() {
  1872. + loadErrAt = loadErrAt.UTC()
  1873. + snapshot.ConfigLoadErrorAt = &loadErrAt
  1874. + }
  1875. }
  1876. if repo.StorageSupported() {
  1877. if stats, err := repo.RuntimeDBStats(ctx); err == nil {
  1878. @@ -93,13 +118,21 @@ func Runtime(ctx context.Context, repo JobRepository, configSvc *ConfigService)
  1879. }
  1880. if cfg.Enabled && !snapshot.StorageSupported {
  1881. snapshot.ProcessStatus = "error"
  1882. - snapshot.LastErrorCode = "storage_not_supported"
  1883. snapshot.LastErrorMessage = "提示词审计日志 Ent 客户端未初始化"
  1884. + if cfg.BlockingEnabled {
  1885. + snapshot.ProcessStatus = "degraded"
  1886. + snapshot.LastErrorMessage += ";同步判定仍可执行,但结果记录降级"
  1887. + }
  1888. + snapshot.LastErrorCode = "storage_not_supported"
  1889. }
  1890. if cfg.Enabled && (defaultPayloadStore == nil || !defaultPayloadStore.Available()) {
  1891. snapshot.ProcessStatus = "error"
  1892. - snapshot.LastErrorCode = "payload_store_unavailable"
  1893. snapshot.LastErrorMessage = ErrPayloadStoreUnavailable.Error()
  1894. + if cfg.BlockingEnabled {
  1895. + snapshot.ProcessStatus = "degraded"
  1896. + snapshot.LastErrorMessage += ";同步判定不依赖异步载荷存储"
  1897. + }
  1898. + snapshot.LastErrorCode = "payload_store_unavailable"
  1899. }
  1900. if cfg.Enabled && snapshot.PayloadStoreDegraded && snapshot.ProcessStatus != "error" {
  1901. snapshot.ProcessStatus = "degraded"
  1902. @@ -108,9 +141,28 @@ func Runtime(ctx context.Context, repo JobRepository, configSvc *ConfigService)
  1903. snapshot.LastErrorMessage = "提示词审计正在使用内存 payload store,仅适合单进程开发或测试"
  1904. }
  1905. }
  1906. + if configLoadErr != nil {
  1907. + if snapshot.ProcessStatus != "error" {
  1908. + snapshot.ProcessStatus = "degraded"
  1909. + }
  1910. + if snapshot.LastErrorCode == "" {
  1911. + snapshot.LastErrorCode = "config_load_failed"
  1912. + snapshot.LastErrorMessage = "提示词审计配置加载失败"
  1913. + }
  1914. + }
  1915. return snapshot
  1916. }
  1917. +func effectivePromptAuditMode(enabled bool, blockingEnabled bool) string {
  1918. + if !enabled {
  1919. + return "off"
  1920. + }
  1921. + if blockingEnabled {
  1922. + return "blocking"
  1923. + }
  1924. + return "async_audit"
  1925. +}
  1926. +
  1927. func (r *Runner) heartbeatLoop(ctx context.Context, cfg Config) {
  1928. defer r.wg.Done()
  1929. ticker := time.NewTicker(10 * time.Second)
  1930. diff --git a/ai-gateway/internal/service/promptaudit/runtime_coverage_test.go b/ai-gateway/internal/service/promptaudit/runtime_coverage_test.go
  1931. index ebbf045b144ca3c212fbfc19d3606c729264b9e2..6fe7669763d4110c1fa3b1028ea67dffb5d507bb 100644
  1932. --- a/ai-gateway/internal/service/promptaudit/runtime_coverage_test.go
  1933. +++ b/ai-gateway/internal/service/promptaudit/runtime_coverage_test.go
  1934. @@ -86,6 +86,8 @@ func TestRuntimeMergesDBStatsAndHeartbeatWithObservablePriority(t *testing.T) {
  1935. func TestRuntimeFallsBackToSafeConfigWhenConfigPublicLoadFails(t *testing.T) {
  1936. clearRuntimeHeartbeatForTesting()
  1937. + ClearConfigCache()
  1938. + t.Cleanup(ClearConfigCache)
  1939. repo := newFakePromptAuditRepo()
  1940. svc := NewConfigService(&configurableOptionStore{
  1941. values: map[string]string{},
  1942. @@ -100,8 +102,14 @@ func TestRuntimeFallsBackToSafeConfigWhenConfigPublicLoadFails(t *testing.T) {
  1943. if snapshot.WorkerTotal != 4 || snapshot.QueueCapacity != 10000 {
  1944. t.Fatalf("runtime safe defaults mismatch: worker=%d capacity=%d", snapshot.WorkerTotal, snapshot.QueueCapacity)
  1945. }
  1946. - if snapshot.ProcessStatus == "error" || snapshot.LastErrorCode != "" {
  1947. - t.Fatalf("config read failure should not fabricate runner error state, got status=%s code=%s", snapshot.ProcessStatus, snapshot.LastErrorCode)
  1948. + if snapshot.ProcessStatus != "degraded" || snapshot.LastErrorCode != "config_load_failed" {
  1949. + t.Fatalf("配置读取失败应明确标记运行态降级,got status=%s code=%s", snapshot.ProcessStatus, snapshot.LastErrorCode)
  1950. + }
  1951. + if snapshot.ConfigLoadError != "提示词审计配置加载失败" || snapshot.ConfigLoadErrorAt == nil {
  1952. + t.Fatalf("配置读取失败应只暴露通用脱敏错误,got error=%q at=%v", snapshot.ConfigLoadError, snapshot.ConfigLoadErrorAt)
  1953. + }
  1954. + if strings.Contains(snapshot.ConfigLoadError, "option storage unavailable") || strings.Contains(snapshot.LastErrorMessage, "option storage unavailable") {
  1955. + t.Fatalf("运行态不得泄露底层配置存储错误: %+v", snapshot)
  1956. }
  1957. }
  1958. diff --git a/ai-gateway/internal/service/promptaudit/types.go b/ai-gateway/internal/service/promptaudit/types.go
  1959. index c6d9f4f0ccd1257ee94614156a597fc7870a5545..ca5254997fafff386e044dfebb1e9e7dea0f1093 100644
  1960. --- a/ai-gateway/internal/service/promptaudit/types.go
  1961. +++ b/ai-gateway/internal/service/promptaudit/types.go
  1962. @@ -53,16 +53,17 @@ type PayloadStore interface {
  1963. }
  1964. type ScanPromptContext struct {
  1965. - JobID int64
  1966. - RequestID string
  1967. - UserID int
  1968. - TokenID int
  1969. - ChannelID int
  1970. - Endpoint string
  1971. - Protocol string
  1972. - Model string
  1973. - Group string
  1974. - Lease *ScanPromptLease
  1975. + JobID int64
  1976. + RequestID string
  1977. + UserID int
  1978. + TokenID int
  1979. + ChannelID int
  1980. + Endpoint string
  1981. + Protocol string
  1982. + Model string
  1983. + Group string
  1984. + StopOnBlock bool
  1985. + Lease *ScanPromptLease
  1986. }
  1987. // ScanPromptLease 允许长文本分片扫描在每片开始前刷新 processing 租约,
  1988. @@ -370,28 +371,36 @@ type RuntimeDBStats struct {
  1989. }
  1990. type RuntimeSnapshot struct {
  1991. - Enabled bool `json:"enabled"`
  1992. - ProcessStatus string `json:"process_status"`
  1993. - QueueLength int `json:"queue_length"`
  1994. - QueueCapacity int `json:"queue_capacity"`
  1995. - WorkerTotal int `json:"worker_total"`
  1996. - ActiveWorkers int64 `json:"active_workers"`
  1997. - Enqueued int64 `json:"enqueued"`
  1998. - Dropped int64 `json:"dropped"`
  1999. - ProcessedTotal int64 `json:"processed_total"`
  2000. - FailedTotal int64 `json:"failed_total"`
  2001. - LastErrorCode string `json:"last_error_code"`
  2002. - LastErrorMessage string `json:"last_error_message"`
  2003. - LLMGuardConnectivity string `json:"llm_guard_connectivity"`
  2004. - StorageSupported bool `json:"storage_supported"`
  2005. - QueueBackend string `json:"queue_backend"`
  2006. - PayloadStore string `json:"payload_store"`
  2007. - PayloadStoreDegraded bool `json:"payload_store_degraded"`
  2008. - QueuedRows int64 `json:"queued_rows"`
  2009. - ProcessingRows int64 `json:"processing_rows"`
  2010. - LastEnqueuedAt *time.Time `json:"last_enqueued_at"`
  2011. - LastProcessedAt *time.Time `json:"last_processed_at"`
  2012. - LastFailedAt *time.Time `json:"last_failed_at"`
  2013. - HeartbeatAt *time.Time `json:"heartbeat_at"`
  2014. - Config PublicConfig `json:"config"`
  2015. + Enabled bool `json:"enabled"`
  2016. + BlockingEnabled bool `json:"blocking_enabled"`
  2017. + EffectiveMode string `json:"effective_mode"`
  2018. + ExpectedConfigVersion int64 `json:"expected_config_version"`
  2019. + ActiveConfigVersion int64 `json:"active_config_version"`
  2020. + ConfigLoadedAt *time.Time `json:"config_loaded_at,omitempty"`
  2021. + ConfigLoadError string `json:"config_load_error,omitempty"`
  2022. + ConfigLoadErrorAt *time.Time `json:"config_load_error_at,omitempty"`
  2023. + ProcessStatus string `json:"process_status"`
  2024. + QueueLength int `json:"queue_length"`
  2025. + QueueCapacity int `json:"queue_capacity"`
  2026. + WorkerTotal int `json:"worker_total"`
  2027. + ActiveWorkers int64 `json:"active_workers"`
  2028. + Enqueued int64 `json:"enqueued"`
  2029. + Dropped int64 `json:"dropped"`
  2030. + ProcessedTotal int64 `json:"processed_total"`
  2031. + FailedTotal int64 `json:"failed_total"`
  2032. + LastErrorCode string `json:"last_error_code"`
  2033. + LastErrorMessage string `json:"last_error_message"`
  2034. + LLMGuardConnectivity string `json:"llm_guard_connectivity"`
  2035. + StorageSupported bool `json:"storage_supported"`
  2036. + QueueBackend string `json:"queue_backend"`
  2037. + PayloadStore string `json:"payload_store"`
  2038. + PayloadStoreDegraded bool `json:"payload_store_degraded"`
  2039. + QueuedRows int64 `json:"queued_rows"`
  2040. + ProcessingRows int64 `json:"processing_rows"`
  2041. + LastEnqueuedAt *time.Time `json:"last_enqueued_at"`
  2042. + LastProcessedAt *time.Time `json:"last_processed_at"`
  2043. + LastFailedAt *time.Time `json:"last_failed_at"`
  2044. + HeartbeatAt *time.Time `json:"heartbeat_at"`
  2045. + Config PublicConfig `json:"config"`
  2046. + PromptGuardMetrics PromptGuardMetricsSnapshot `json:"prompt_guard_metrics"`
  2047. }
  2048. diff --git a/ai-gateway/internal/service/promptaudit/worker.go b/ai-gateway/internal/service/promptaudit/worker.go
  2049. index 0aec513f15a5120ba9ef83f1a5027a6ec87bbd02..f6e059106fe2af03ee44bb86cc891c0766267341 100644
  2050. --- a/ai-gateway/internal/service/promptaudit/worker.go
  2051. +++ b/ai-gateway/internal/service/promptaudit/worker.go
  2052. @@ -73,10 +73,14 @@ func (r *Runner) Start(ctx context.Context) error {
  2053. return ErrPayloadStoreUnavailable
  2054. }
  2055. workerCount := normalizeWorkerCount(cfg.WorkerCount)
  2056. + installConfigSnapshot(cfg)
  2057. + StartConfigInvalidationSubscriber(ctx)
  2058. LogInfoEvent(
  2059. "prompt_audit.started",
  2060. Field("status", "running"),
  2061. Field("enabled", cfg.Enabled),
  2062. + Field("blocking_enabled", cfg.Enabled && cfg.BlockingEnabled),
  2063. + Field("config_version", normalizeConfigVersion(cfg.ConfigVersion)),
  2064. Field("queue_capacity", cfg.QueueCapacity),
  2065. Field("worker_total", workerCount),
  2066. Field("endpoint_count", len(cfg.EnabledEndpoints())),
  2067. diff --git a/ai-gateway/internal/types/error.go b/ai-gateway/internal/types/error.go
  2068. index 501ee10d7660fb6f6d91baddd44624bbe32120ab..a6a9d55d966b5bb0c50efe8a02ebdef6bfd3252c 100644
  2069. --- a/ai-gateway/internal/types/error.go
  2070. +++ b/ai-gateway/internal/types/error.go
  2071. @@ -75,14 +75,17 @@ const (
  2072. ErrorCodeBadRequestBody ErrorCode = "bad_request_body"
  2073. // response error
  2074. - ErrorCodeReadResponseBodyFailed ErrorCode = "read_response_body_failed"
  2075. - ErrorCodeBadResponseStatusCode ErrorCode = "bad_response_status_code"
  2076. - ErrorCodeBadResponse ErrorCode = "bad_response"
  2077. - ErrorCodeBadResponseBody ErrorCode = "bad_response_body"
  2078. - ErrorCodeEmptyResponse ErrorCode = "empty_response"
  2079. - ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error"
  2080. - ErrorCodeModelNotFound ErrorCode = "model_not_found"
  2081. - ErrorCodePromptBlocked ErrorCode = "prompt_blocked"
  2082. + ErrorCodeReadResponseBodyFailed ErrorCode = "read_response_body_failed"
  2083. + ErrorCodeBadResponseStatusCode ErrorCode = "bad_response_status_code"
  2084. + ErrorCodeBadResponse ErrorCode = "bad_response"
  2085. + ErrorCodeBadResponseBody ErrorCode = "bad_response_body"
  2086. + ErrorCodeEmptyResponse ErrorCode = "empty_response"
  2087. + ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error"
  2088. + ErrorCodeModelNotFound ErrorCode = "model_not_found"
  2089. + ErrorCodePromptBlocked ErrorCode = "prompt_blocked"
  2090. + ErrorCodePromptGuardBlocked ErrorCode = "prompt_guard_blocked"
  2091. + ErrorCodePromptGuardUnavailable ErrorCode = "prompt_guard_unavailable"
  2092. + ErrorCodePromptGuardInvalidResponse ErrorCode = "prompt_guard_invalid_response"
  2093. // sql error
  2094. ErrorCodeQueryDataError ErrorCode = "query_data_error"
  2095. diff --git a/deploy/.env.example b/deploy/.env.example
  2096. index 01d5fb78a0550abcdcd9444088c38cc1404148bb..19bb22221b9e1520da78e73c94b7ea7e6ea4a705 100644
  2097. --- a/deploy/.env.example
  2098. +++ b/deploy/.env.example
  2099. @@ -306,22 +306,28 @@ AICODEX_REALTIME_WS_OUTBOUND_QUEUE_SIZE=64
  2100. # AICODEX_REALTIME_WS_ALLOWED_ORIGINS=https://console.example.com
  2101. # --- 用户输入提示词审计(默认关闭)---
  2102. -# 主 aicodex 进程内置审计 worker:模型请求只异步入队,worker 调用外部 LLM Guard HTTP API 扫描。
  2103. -# 开启前必须配置可用的 LLM Guard API,并确保 Redis 可用(完整提示词只以短 TTL 临时载荷写入 Redis,不落库)。
  2104. +# 主 aicodex 进程内置 Qwen3Guard 审计 worker;默认异步只审计,可显式开启同步阻止。
  2105. +# 开启前必须配置可用的 OpenAI 兼容 Guard;异步载荷使用 Redis 短 TTL,同步路径不持久化原文。
  2106. PROMPT_AUDIT_ENABLED=false
  2107. +# true=请求在渠道选择、计费和上游调用前同步等待 Guard;Block 或 Guard 不可用时 fail-closed。
  2108. +PROMPT_AUDIT_BLOCKING_ENABLED=false
  2109. # 是否持久化 pass 事件;默认 false,仅保存 flag / critical 等风险事件。
  2110. PROMPT_AUDIT_STORE_PASS_EVENTS=false
  2111. -# LLM Guard API 选择策略:priority / weighted / shadow
  2112. +# Guard 节点调度仅支持 priority(有序故障切换)。
  2113. PROMPT_AUDIT_STRATEGY=priority
  2114. # 主进程内审计 worker 数;建议按外部 LLM Guard API 实际吞吐灰度调大。
  2115. PROMPT_AUDIT_WORKER_COUNT=4
  2116. # 队列容量上限;达到上限时主请求继续转发,并输出 prompt_audit.enqueue_dropped。
  2117. PROMPT_AUDIT_QUEUE_CAPACITY=10000
  2118. -# 输入扫描器列表,逗号分隔。
  2119. -PROMPT_AUDIT_SCANNERS=PromptInjection,TokenLimit,Secrets,InvisibleText,Gibberish,Regex
  2120. -# Guard Prompt Scan URL,多个地址用逗号分隔;推荐直接填写完整审核路由。
  2121. -# 生产可使用外部审核服务,例如:https://scan.leagsoft.com/v1/scan/prompt
  2122. -LLM_GUARD_SCAN_URLS=https://scan.leagsoft.com/v1/scan/prompt
  2123. +# Qwen3Guard 输入类别,逗号分隔。
  2124. +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
  2125. +# 推荐使用 OpenAI 兼容 Base URL、模型和 API Key;公网地址必须 HTTPS。
  2126. +PROMPT_AUDIT_BASE_URLS=
  2127. +PROMPT_AUDIT_MODEL=sileader/qwen3guard:0.6b
  2128. +PROMPT_AUDIT_API_KEYS=
  2129. +PROMPT_AUDIT_TIMEOUT_MS=30000
  2130. +# 旧变量兼容:按 OpenAI 兼容 Base URL 解释,多个地址用逗号分隔;新部署优先使用 PROMPT_AUDIT_BASE_URLS。
  2131. +LLM_GUARD_SCAN_URLS=
  2132. # 旧 LLM Guard API Base URL 兼容变量;为空时优先使用 LLM_GUARD_SCAN_URLS。
  2133. # 如果只填写服务根地址,AICodex 会兼容补齐 /v1/scan/prompt。
  2134. LLM_GUARD_API_BASE_URLS=
  2135. diff --git a/docs/constraints/41-ai-readable-logging.md b/docs/constraints/41-ai-readable-logging.md
  2136. index 7be9278ffd3ad7468fda6d21118ef387fc2b3075..7f2df768ab3e3d60de6ae7e0f3a0913fb66ab65f 100644
  2137. --- a/docs/constraints/41-ai-readable-logging.md
  2138. +++ b/docs/constraints/41-ai-readable-logging.md
  2139. @@ -592,3 +592,36 @@ App 下载中心以华为云 OBS/CDN updater metadata 为事实源时,必须
  2140. metadata 缓存事件必须用 `cache_key_hash` 表示缓存键,不得输出完整缓存键、完整 metadata URL、完整 package URL 或 metadata body。`ETag` 与 `Last-Modified` 只允许输出是否存在的布尔值,不得输出 header 原文。
  2141. 禁止输出 Cookie、Authorization、完整带 query 的 URL、OBS 密钥、CDN 鉴权参数或安装包签名 URL 原文。日志中如需表示 metadata 或 package 来源,只能输出 host、平台键、文件名和允许范围内的稳定状态字段。
  2142. +
  2143. +## 13. Prompt Guard 同步门禁专项约束
  2144. +
  2145. +提示词同步阻止属于请求副作用边界,日志必须能直接回答“使用了哪个配置版本、为什么放行或拒绝、拒绝前是否触发渠道/计费/上游”。
  2146. +
  2147. +稳定事件名:
  2148. +
  2149. +- `prompt_guard.config_updated`
  2150. +- `prompt_guard.config_loaded`
  2151. +- `prompt_guard.config_reload_degraded`
  2152. +- `prompt_guard.evaluation_started`
  2153. +- `prompt_guard.allowed`
  2154. +- `prompt_guard.blocked`
  2155. +- `prompt_guard.failed`
  2156. +- `prompt_guard.result_record_failed`
  2157. +
  2158. +最小字段集合:
  2159. +
  2160. +- `request_id`、`user_id`、`token_id`、`group`
  2161. +- `protocol`、`endpoint`、`model`
  2162. +- `config_version`、`policy_id`、`policy_version`、`guard_endpoint_id`
  2163. +- `decision`、`action`、`chunk_total`、`latency_ms`
  2164. +- `status`、`error_code`、`stage`
  2165. +- `upstream_dispatched`、`billing_preconsumed`
  2166. +
  2167. +稳定错误码:
  2168. +
  2169. +- `prompt_guard_blocked`
  2170. +- `prompt_guard_unavailable`
  2171. +- `prompt_guard_invalid_response`
  2172. +- `prompt_guard_requires_audit_enabled`
  2173. +
  2174. +Block、Unavailable 和非法响应日志必须明确 `upstream_dispatched=false`、`billing_preconsumed=false`。禁止输出完整提示词、原始分片、API Key、Token、Authorization、完整 Guard URL、URL query、Guard 原始响应或内部优先分片边界。分类只允许输出归一化后的类别与稳定 scanner 名称。
  2175. diff --git a/docs/workflows/02-local-dev.md b/docs/workflows/02-local-dev.md
  2176. index 2ca1e966f237ae170aebbc15c5e461f2b05f3cde..683b5dcd389508b7e079702c7f5e614fa145db5f 100644
  2177. --- a/docs/workflows/02-local-dev.md
  2178. +++ b/docs/workflows/02-local-dev.md
  2179. @@ -25,21 +25,24 @@
  2180. ### 提示词审计本地验证
  2181. -提示词审计由主 `aicodex` 进程异步投递任务,并在主进程内置 worker 中消费任务、调用外部 Guard Prompt Scan URL。完整原始提示词只会以短 TTL 临时载荷写入 Redis,数据库只保存 hash、脱敏预览、上下文和扫描结果。
  2182. +提示词审计支持两种执行模式:`blocking_enabled=false` 为异步只审计;`blocking_enabled=true` 为同步阻止。同步模式会在渠道选择、计费预扣和上游调用前调用 OpenAI 兼容 Qwen3Guard,命中 Block 返回 403,Guard 不可用或输出非法返回 503。同步结果直接复用到脱敏事件,不重复调用 Guard。
  2183. -- 推荐在 `deploy/.env` 中设置新的完整审核 URL 和 API Key:
  2184. +- 推荐在控制台保存配置;也可在 `deploy/.env` 中设置 OpenAI 兼容 Guard:
  2185. - `PROMPT_AUDIT_ENABLED=true`
  2186. - - `LLM_GUARD_SCAN_URLS=https://scan.leagsoft.com/v1/scan/prompt`
  2187. - - `LLM_GUARD_API_TOKENS=sk-lg_xxx`,需替换为本地私有 API Key;如果外接多个 Guard API,可用逗号分隔,且不得提交真实 key。
  2188. + - `PROMPT_AUDIT_BLOCKING_ENABLED=false`(先以异步模式建立基线,灰度时再开启)
  2189. + - `PROMPT_AUDIT_BASE_URLS=https://guard.example.com/v1`
  2190. + - `PROMPT_AUDIT_MODEL=sileader/qwen3guard:0.6b`
  2191. + - `PROMPT_AUDIT_API_KEYS=sk_xxx`,只写入本地私有配置,不得提交真实 key。
  2192. - 旧 `laiyer/llm-guard-api` sidecar 仅作为迁移兼容样例保留:
  2193. - 随主栈 profile 启动:`cd deploy && docker compose -p aicodex --profile prompt-audit-llm-guard up -d llm-guard-api`
  2194. - 使用旧 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`。
  2195. - 启动主服务:`cd deploy && docker compose -p aicodex up -d aicodex`
  2196. -- 查看主服务审计日志:`cd deploy && docker compose -p aicodex logs -f aicodex | grep prompt_audit`
  2197. -- 连通性验证:登录控制台打开“HTTP 审计 → 提示词审计”后点击 endpoint 探测,或调用 `POST /api/prompt-audit/endpoints/probe`;后端会优先检查审核 URL 所属 origin 的 `/health`,必要时用安全探针调用 `scan_url`。
  2198. -- 运行态验证:调用 `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`。
  2199. +- 查看主服务审计日志:`cd deploy && docker compose -p aicodex logs -f aicodex | grep -E 'prompt_audit|prompt_guard'`
  2200. +- 连通性验证:登录控制台打开“提示词审计”后点击审计池探测,或调用 `POST /api/prompt-audit/endpoints/probe`。公网 Guard 只允许 HTTPS;HTTP 仅允许 localhost、单标签内部服务名或显式私网 IP;重定向、link-local 和云元数据地址会被拒绝。
  2201. +- 运行态验证:调用 `GET /api/prompt-audit/runtime`,确认 `effective_mode`、`expected_config_version`、`active_config_version`、`config_loaded_at`、`process_status` 和 `llm_guard_connectivity` 符合预期。版本不一致或 `config_load_error` 非空时不得扩大灰度。
  2202. - 事件落库验证:发送包含 PromptInjection 特征的 `/v1/chat/completions`、`/v1/responses` 或 Claude Messages 请求,再查询 `GET /api/prompt-audit/events?decision=critical`;页面和接口只能展示脱敏预览、hash、scanner 命中和处理元数据。
  2203. -- 回滚方式:将 `PROMPT_AUDIT_ENABLED=false` 后重启主服务;提示词审计是异步旁路,关闭后不会影响主模型请求转发,已写入的审计事件可继续保留用于复核。
  2204. +- 同步验证:先用良性输入确认请求成功,再用 fake Guard 分别返回 `Safety: Unsafe / Categories: Jailbreak`、超时和非法格式,确认 HTTP 分别返回 403/503,且上游调用数、渠道重试数和预扣次数均为 0;Responses WebSocket 首轮和后续 `response.create` 也必须在本轮预扣前检查。
  2205. +- 回滚方式:在控制台关闭“同步阻止”并保存,即刻恢复异步只审计;无需关闭审计或删除历史事件。若需完全停用,再关闭“启用审计”。
  2206. ## 前端
  2207. diff --git a/webui/src/api/promptAudit.test.ts b/webui/src/api/promptAudit.test.ts
  2208. index 2c1030573522eacc893b4f47c77ee26563ccbeb5..94f2a5f07a1d18bca2de73c7c22e49f49f65394c 100644
  2209. --- a/webui/src/api/promptAudit.test.ts
  2210. +++ b/webui/src/api/promptAudit.test.ts
  2211. @@ -76,6 +76,7 @@ const eventListParams: PromptAuditEventListParams = {
  2212. const savePayload: PromptAuditConfigSavePayload = {
  2213. enabled: true,
  2214. + blocking_enabled: false,
  2215. store_pass_events: false,
  2216. strategy: 'priority',
  2217. worker_count: 16,
  2218. diff --git a/webui/src/features/prompt-audit/PromptAuditPage.test.tsx b/webui/src/features/prompt-audit/PromptAuditPage.test.tsx
  2219. index ede5204bd9a2d01541b09d9ab2468d3d98f7b4b4..243d722a91d77b2a8ec077825a804341cccd0e9d 100644
  2220. --- a/webui/src/features/prompt-audit/PromptAuditPage.test.tsx
  2221. +++ b/webui/src/features/prompt-audit/PromptAuditPage.test.tsx
  2222. @@ -80,6 +80,8 @@ const savePromptAuditConfigMock = vi.mocked(savePromptAuditConfig)
  2223. const configResponse: PromptAuditConfigResponse = {
  2224. enabled: true,
  2225. + blocking_enabled: false,
  2226. + config_version: 3,
  2227. store_pass_events: false,
  2228. strategy: 'priority',
  2229. worker_count: 16,
  2230. @@ -103,6 +105,10 @@ const configResponse: PromptAuditConfigResponse = {
  2231. const runtimeResponse: PromptAuditRuntime = {
  2232. enabled: true,
  2233. + blocking_enabled: false,
  2234. + effective_mode: 'async_audit',
  2235. + expected_config_version: 3,
  2236. + active_config_version: 3,
  2237. process_status: 'running',
  2238. queue_length: 0,
  2239. queue_capacity: 10000,
  2240. @@ -267,6 +273,7 @@ describe('PromptAuditPage', () => {
  2241. expect(savePromptAuditConfigMock).toHaveBeenCalledWith(
  2242. expect.objectContaining({
  2243. enabled: true,
  2244. + blocking_enabled: false,
  2245. store_pass_events: false,
  2246. worker_count: 16,
  2247. queue_capacity: 10000,
  2248. @@ -290,6 +297,77 @@ describe('PromptAuditPage', () => {
  2249. expect(screen.getByLabelText('主审计池 API Key')).toHaveValue('')
  2250. })
  2251. + it('同步阻止需要确认,保存后关闭审计会自动关闭阻止', async () => {
  2252. + confirmImmediately()
  2253. + const user = userEvent.setup()
  2254. + renderWithRouter(<PromptAuditPage />)
  2255. +
  2256. + const blockingSwitch = await screen.findByRole('switch', {
  2257. + name: '同步阻止',
  2258. + })
  2259. + expect(blockingSwitch).not.toBeChecked()
  2260. + await user.click(blockingSwitch)
  2261. + expect(Modal.confirm).toHaveBeenCalledWith(
  2262. + expect.objectContaining({ title: '确认开启同步阻止' }),
  2263. + )
  2264. + expect(blockingSwitch).toBeChecked()
  2265. +
  2266. + await user.click(screen.getByRole('button', { name: /保存配置/ }))
  2267. + await waitFor(() => {
  2268. + expect(savePromptAuditConfigMock).toHaveBeenCalledWith(
  2269. + expect.objectContaining({ blocking_enabled: true }),
  2270. + )
  2271. + })
  2272. +
  2273. + await user.click(screen.getByRole('switch', { name: '启用审计' }))
  2274. + expect(blockingSwitch).not.toBeChecked()
  2275. + expect(blockingSwitch).toBeDisabled()
  2276. + })
  2277. +
  2278. + it('取消同步阻止风险确认时保持异步只审计草稿', async () => {
  2279. + vi.spyOn(Modal, 'confirm').mockImplementation(() => undefined as never)
  2280. + const user = userEvent.setup()
  2281. + renderWithRouter(<PromptAuditPage />)
  2282. +
  2283. + const blockingSwitch = await screen.findByRole('switch', {
  2284. + name: '同步阻止',
  2285. + })
  2286. + await user.click(blockingSwitch)
  2287. +
  2288. + expect(Modal.confirm).toHaveBeenCalledWith(
  2289. + expect.objectContaining({ title: '确认开启同步阻止' }),
  2290. + )
  2291. + expect(blockingSwitch).not.toBeChecked()
  2292. + expect(screen.getByText('异步只审计')).toBeInTheDocument()
  2293. + expect(savePromptAuditConfigMock).not.toHaveBeenCalled()
  2294. + })
  2295. +
  2296. + it('未保存草稿不得冒充运行时审计状态', async () => {
  2297. + const user = userEvent.setup()
  2298. + renderWithRouter(<PromptAuditPage />)
  2299. +
  2300. + expect(await screen.findByText('审计已启用')).toBeInTheDocument()
  2301. + await user.click(screen.getByRole('switch', { name: '启用审计' }))
  2302. +
  2303. + expect(screen.getByText('审计已启用')).toBeInTheDocument()
  2304. + expect(screen.queryByText('审计未启用')).not.toBeInTheDocument()
  2305. + expect(screen.getByText('有未保存更改')).toBeInTheDocument()
  2306. + })
  2307. +
  2308. + it('运行态配置版本不一致时明确展示降级提示和双版本', async () => {
  2309. + fetchPromptAuditRuntimeMock.mockResolvedValueOnce({
  2310. + ...runtimeResponse,
  2311. + expected_config_version: 4,
  2312. + active_config_version: 3,
  2313. + config_load_error: 'config_load_failed',
  2314. + })
  2315. + renderWithRouter(<PromptAuditPage />)
  2316. +
  2317. + expect(await screen.findByText('配置版本未同步')).toBeInTheDocument()
  2318. + expect(screen.getByText('期望版本: 4')).toBeInTheDocument()
  2319. + expect(screen.getByText('生效版本: 3')).toBeInTheDocument()
  2320. + })
  2321. +
  2322. it('通过参数弹框修改权重、超时和单片输入上限后统一保存', async () => {
  2323. const user = userEvent.setup()
  2324. renderWithRouter(<PromptAuditPage />)
  2325. diff --git a/webui/src/features/prompt-audit/PromptAuditPage.tsx b/webui/src/features/prompt-audit/PromptAuditPage.tsx
  2326. index 2bc1aa5cd959a28dc79675344ba518132a49f104..03228aae0ff26f971e83cba86262d6202ae9259e 100644
  2327. --- a/webui/src/features/prompt-audit/PromptAuditPage.tsx
  2328. +++ b/webui/src/features/prompt-audit/PromptAuditPage.tsx
  2329. @@ -306,6 +306,31 @@ const PromptAuditPage = () => {
  2330. value: PromptAuditConfigState[K],
  2331. ) => setConfig((current) => ({ ...current, [key]: value }))
  2332. + const updateAuditEnabled = (enabled: boolean) => {
  2333. + setConfig((current) => ({
  2334. + ...current,
  2335. + enabled,
  2336. + blockingEnabled: enabled ? current.blockingEnabled : false,
  2337. + }))
  2338. + }
  2339. +
  2340. + const updateBlockingEnabled = (enabled: boolean) => {
  2341. + if (!enabled) {
  2342. + updateConfig('blockingEnabled', false)
  2343. + return
  2344. + }
  2345. + if (!config.enabled) return
  2346. + Modal.confirm({
  2347. + title: t('确认开启同步阻止'),
  2348. + content: t(
  2349. + '开启后,请求会在转发前等待 Guard 判定;命中 Block 或 Guard 不可用时不会访问上游,并将分别返回 403 或 503。',
  2350. + ),
  2351. + okText: t('确认开启'),
  2352. + cancelText: t('取消'),
  2353. + onOk: () => updateConfig('blockingEnabled', true),
  2354. + })
  2355. + }
  2356. +
  2357. const updateEndpoint = (
  2358. endpointID: string,
  2359. patch: Partial<PromptAuditEndpointState>,
  2360. @@ -383,7 +408,7 @@ const PromptAuditPage = () => {
  2361. const loadRuntime = useCallback(async () => {
  2362. const response = await fetchPromptAuditRuntime()
  2363. setRuntime(response)
  2364. - if (response.config) {
  2365. + if (response.config && savedSnapshotRef.current === '') {
  2366. const normalized = normalizePromptAuditConfig(response.config)
  2367. setConfig({
  2368. ...normalized,
  2369. @@ -512,6 +537,24 @@ const PromptAuditPage = () => {
  2370. config.auditGroupMode === 'all' || groupsLoading || groupsLoadFailed
  2371. const queueBacklog = Number(runtime?.queue_length || 0) > 0
  2372. + const runtimeEffectiveMode =
  2373. + runtime?.effective_mode ||
  2374. + (runtime?.enabled
  2375. + ? runtime?.blocking_enabled
  2376. + ? 'blocking'
  2377. + : 'async_audit'
  2378. + : 'off')
  2379. + const runtimeModeLabel =
  2380. + runtimeEffectiveMode === 'blocking'
  2381. + ? t('同步阻止')
  2382. + : runtimeEffectiveMode === 'async_audit'
  2383. + ? t('异步只审计')
  2384. + : t('审计关闭')
  2385. + const runtimeVersionMismatch =
  2386. + Number(runtime?.expected_config_version || 0) > 0 &&
  2387. + Number(runtime?.expected_config_version) !==
  2388. + Number(runtime?.active_config_version || 0)
  2389. + const runtimeAuditEnabled = runtime?.enabled ?? config.enabled
  2390. const saveConfig = useCallback(async () => {
  2391. if (saving) return
  2392. @@ -534,6 +577,7 @@ const PromptAuditPage = () => {
  2393. commitSavedSnapshot(buildConfigSnapshot(nextConfig))
  2394. }
  2395. Toast.success(t('提示词审计配置已保存'))
  2396. + await loadRuntime()
  2397. } catch (error: unknown) {
  2398. const message = readPromptAuditErrorMessage(
  2399. error,
  2400. @@ -544,7 +588,7 @@ const PromptAuditPage = () => {
  2401. } finally {
  2402. setSaving(false)
  2403. }
  2404. - }, [commitSavedSnapshot, config, saving, t])
  2405. + }, [commitSavedSnapshot, config, loadRuntime, saving, t])
  2406. useEffect(() => {
  2407. const onKeyDown = (event: KeyboardEvent) => {
  2408. @@ -1216,12 +1260,23 @@ const PromptAuditPage = () => {
  2409. icon={<ShieldCheck size={18} />}
  2410. actions={
  2411. <div className='security-audit-header-status'>
  2412. - <Tag color={config.enabled ? 'green' : 'grey'} size='large'>
  2413. - {config.enabled ? t('审计已启用') : t('审计未启用')}
  2414. + <Tag color={runtimeAuditEnabled ? 'green' : 'grey'} size='large'>
  2415. + {runtimeAuditEnabled ? t('审计已启用') : t('审计未启用')}
  2416. </Tag>
  2417. <Tag color={runtimeStatusTag.color} size='large'>
  2418. {runtime?.process_status || runtimeStatusTag.label}
  2419. </Tag>
  2420. + <Tag
  2421. + color={runtimeEffectiveMode === 'blocking' ? 'red' : 'blue'}
  2422. + size='large'
  2423. + >
  2424. + {runtimeModeLabel}
  2425. + </Tag>
  2426. + {runtimeVersionMismatch ? (
  2427. + <Tag color='orange' size='large'>
  2428. + {t('配置版本未同步')}
  2429. + </Tag>
  2430. + ) : null}
  2431. {isDirty ? (
  2432. <Tag color='orange' size='large'>
  2433. {t('有未保存更改')}
  2434. @@ -1292,7 +1347,9 @@ const PromptAuditPage = () => {
  2435. <div className='prompt-audit-overview__chips'>
  2436. <Tag>{`${t('Worker 数')}: ${config.workerCount}`}</Tag>
  2437. <Tag>{`${t('队列容量')}: ${config.queueCapacity}`}</Tag>
  2438. - <Tag>{`${t('调度策略')}: ${config.strategy === 'round_robin' ? t('轮询') : t('优先级')}`}</Tag>
  2439. + <Tag>{`${t('调度策略')}: ${t('优先级故障切换')}`}</Tag>
  2440. + <Tag>{`${t('期望版本')}: ${runtime?.expected_config_version || config.configVersion}`}</Tag>
  2441. + <Tag>{`${t('生效版本')}: ${runtime?.active_config_version || 0}`}</Tag>
  2442. </div>
  2443. <div className='prompt-audit-event-row'>
  2444. <code>prompt_audit.started</code>
  2445. @@ -1348,20 +1405,7 @@ const PromptAuditPage = () => {
  2446. </div>
  2447. </div>
  2448. <Space wrap>
  2449. - <Select
  2450. - value={config.strategy}
  2451. - onChange={(value) =>
  2452. - updateConfig('strategy', String(value))
  2453. - }
  2454. - style={{ width: 180 }}
  2455. - >
  2456. - <Select.Option value='priority'>
  2457. - {t('优先级')}
  2458. - </Select.Option>
  2459. - <Select.Option value='round_robin'>
  2460. - {t('轮询')}
  2461. - </Select.Option>
  2462. - </Select>
  2463. + <Tag color='blue'>{t('优先级故障切换')}</Tag>
  2464. <Button icon={<Plus size={16} />} onClick={addEndpoint}>
  2465. {t('添加审计池')}
  2466. </Button>
  2467. @@ -2521,8 +2565,27 @@ const PromptAuditPage = () => {
  2468. </span>
  2469. <Switch
  2470. checked={config.enabled}
  2471. - onChange={(enabled) => updateConfig('enabled', enabled)}
  2472. + onChange={updateAuditEnabled}
  2473. + size='small'
  2474. + aria-label={t('启用审计')}
  2475. + />
  2476. + </label>
  2477. + <label
  2478. + className='prompt-audit-save-bar__switch'
  2479. + title={t(
  2480. + '关闭时异步只审计;开启时请求在转发前等待 Guard,命中 Block 或 Guard 不可用都会阻止请求。',
  2481. + )}
  2482. + >
  2483. + <span className='prompt-audit-switch-copy'>
  2484. + <ShieldCheck size={14} />
  2485. + <Text size='small'>{t('同步阻止')}</Text>
  2486. + </span>
  2487. + <Switch
  2488. + checked={config.enabled && config.blockingEnabled}
  2489. + disabled={!config.enabled}
  2490. + onChange={updateBlockingEnabled}
  2491. size='small'
  2492. + aria-label={t('同步阻止')}
  2493. />
  2494. </label>
  2495. <label className='prompt-audit-save-bar__switch'>
  2496. diff --git a/webui/src/features/prompt-audit/promptAuditViewModel.test.ts b/webui/src/features/prompt-audit/promptAuditViewModel.test.ts
  2497. index b90cc77727e54a1c22552a1d9f054ef700732ed4..0163323fa9603bbe7854ebaf58cdf84cfd88d126 100644
  2498. --- a/webui/src/features/prompt-audit/promptAuditViewModel.test.ts
  2499. +++ b/webui/src/features/prompt-audit/promptAuditViewModel.test.ts
  2500. @@ -68,6 +68,8 @@ import type {
  2501. const configResponse = {
  2502. enabled: true,
  2503. + blocking_enabled: true,
  2504. + config_version: 7,
  2505. store_pass_events: false,
  2506. strategy: 'priority',
  2507. worker_count: 16,
  2508. @@ -333,6 +335,8 @@ describe('promptAuditViewModel', () => {
  2509. const config = normalizePromptAuditConfig(configResponse)
  2510. expect(config).toMatchObject({
  2511. enabled: true,
  2512. + blockingEnabled: true,
  2513. + configVersion: 7,
  2514. workerCount: '16',
  2515. queueCapacity: '10000',
  2516. scanners: ['Jailbreak', 'PII'],
  2517. @@ -349,11 +353,13 @@ describe('promptAuditViewModel', () => {
  2518. const snapshot = buildConfigSnapshot(config)
  2519. expect(parsePromptAuditSnapshot(snapshot)).toMatchObject({
  2520. enabled: true,
  2521. + blockingEnabled: true,
  2522. auditGroupMode: 'selected',
  2523. })
  2524. expect(parsePromptAuditSnapshot('bad json')).toBeNull()
  2525. expect(buildPromptAuditConfigPayload(config)).toMatchObject({
  2526. enabled: true,
  2527. + blocking_enabled: true,
  2528. worker_count: 16,
  2529. queue_capacity: 10000,
  2530. audit_group_mode: 'selected',
  2531. @@ -371,6 +377,26 @@ describe('promptAuditViewModel', () => {
  2532. })
  2533. })
  2534. + it('旧配置响应缺少同步字段时安全回退到异步只审计', () => {
  2535. + const legacy = normalizePromptAuditConfig({
  2536. + ...configResponse,
  2537. + blocking_enabled: undefined,
  2538. + config_version: undefined,
  2539. + strategy: 'round_robin',
  2540. + })
  2541. + expect(legacy).toMatchObject({
  2542. + enabled: true,
  2543. + blockingEnabled: false,
  2544. + configVersion: 1,
  2545. + strategy: 'priority',
  2546. + })
  2547. + expect(buildPromptAuditConfigPayload(legacy)).toMatchObject({
  2548. + enabled: true,
  2549. + blocking_enabled: false,
  2550. + strategy: 'priority',
  2551. + })
  2552. + })
  2553. +
  2554. it('运行态、探测结果和端点选项保持旧语义', () => {
  2555. const config = normalizePromptAuditConfig(configResponse)
  2556. expect(endpointStatusFromConnectivity('ok', true)).toBe('healthy')
  2557. diff --git a/webui/src/features/prompt-audit/promptAuditViewModel.ts b/webui/src/features/prompt-audit/promptAuditViewModel.ts
  2558. index 9150a61a005585a8a98e5d2aef7c79bd466fa375..fd52612c7cc0761f9f00d09727c4f7f1abd5363c 100644
  2559. --- a/webui/src/features/prompt-audit/promptAuditViewModel.ts
  2560. +++ b/webui/src/features/prompt-audit/promptAuditViewModel.ts
  2561. @@ -151,6 +151,7 @@ export const DEFAULT_PROMPT_AUDIT_ENDPOINTS: PromptAuditEndpointState[] = [
  2562. export const DEFAULT_PROMPT_AUDIT_CONFIG: PromptAuditConfigState = {
  2563. enabled: false,
  2564. + blockingEnabled: false,
  2565. storePassEvents: false,
  2566. strategy: 'priority',
  2567. workerCount: '4',
  2568. @@ -159,6 +160,7 @@ export const DEFAULT_PROMPT_AUDIT_CONFIG: PromptAuditConfigState = {
  2569. auditGroupMode: 'all',
  2570. auditGroups: [],
  2571. endpoints: DEFAULT_PROMPT_AUDIT_ENDPOINTS,
  2572. + configVersion: 1,
  2573. }
  2574. export const DEFAULT_PROMPT_AUDIT_FILTERS: PromptAuditFilters = {
  2575. @@ -793,8 +795,9 @@ export const normalizePromptAuditConfig = (
  2576. }
  2577. return {
  2578. enabled: Boolean(data.enabled),
  2579. + blockingEnabled: Boolean(data.enabled && data.blocking_enabled),
  2580. storePassEvents: Boolean(data.store_pass_events),
  2581. - strategy: data.strategy || 'priority',
  2582. + strategy: 'priority',
  2583. workerCount: String(data.worker_count || 4),
  2584. queueCapacity: String(data.queue_capacity || 10000),
  2585. scanners:
  2586. @@ -804,6 +807,7 @@ export const normalizePromptAuditConfig = (
  2587. auditGroupMode: normalizeAuditGroupMode(data.audit_group_mode),
  2588. auditGroups: normalizeStringList(data.audit_groups),
  2589. endpoints: data.endpoints.map(normalizeEndpointFromAPI),
  2590. + configVersion: Math.max(1, Number(data.config_version || 1)),
  2591. }
  2592. }
  2593. @@ -834,14 +838,16 @@ const endpointEditableSnapshot = (endpoint: PromptAuditEndpointState) => ({
  2594. export const buildConfigSnapshot = (config: PromptAuditConfigState): string =>
  2595. JSON.stringify({
  2596. enabled: config.enabled,
  2597. + blockingEnabled: config.enabled && config.blockingEnabled,
  2598. storePassEvents: config.storePassEvents,
  2599. - strategy: config.strategy || 'priority',
  2600. + strategy: 'priority',
  2601. workerCount: String(config.workerCount ?? ''),
  2602. queueCapacity: String(config.queueCapacity ?? ''),
  2603. scanners: normalizeStringList(config.scanners).sort(),
  2604. auditGroupMode: normalizeAuditGroupMode(config.auditGroupMode),
  2605. auditGroups: normalizeStringList(config.auditGroups).sort(),
  2606. endpoints: config.endpoints.map(endpointEditableSnapshot),
  2607. + configVersion: Math.max(1, Number(config.configVersion || 1)),
  2608. })
  2609. export const parsePromptAuditSnapshot = (
  2610. @@ -876,8 +882,9 @@ export const buildPromptAuditConfigPayload = (
  2611. config: PromptAuditConfigState,
  2612. ): PromptAuditConfigSavePayload => ({
  2613. enabled: config.enabled,
  2614. + blocking_enabled: config.enabled && config.blockingEnabled,
  2615. store_pass_events: config.storePassEvents,
  2616. - strategy: config.strategy,
  2617. + strategy: 'priority',
  2618. worker_count: toPositiveInt(config.workerCount, 4),
  2619. queue_capacity: toPositiveInt(config.queueCapacity, 10000),
  2620. scanners: normalizeStringList(config.scanners),
  2621. diff --git a/webui/src/types/promptAudit.ts b/webui/src/types/promptAudit.ts
  2622. index c99e817e57766960b3f0467572554ddf9bd48771..de68778d5b4af479952f455dd3c495fb49bb5be6 100644
  2623. --- a/webui/src/types/promptAudit.ts
  2624. +++ b/webui/src/types/promptAudit.ts
  2625. @@ -81,6 +81,7 @@ export interface PromptAuditEndpointState {
  2626. export interface PromptAuditConfigResponse {
  2627. enabled?: boolean
  2628. + blocking_enabled?: boolean
  2629. store_pass_events?: boolean
  2630. strategy?: string
  2631. worker_count?: number
  2632. @@ -89,10 +90,14 @@ export interface PromptAuditConfigResponse {
  2633. audit_group_mode?: string
  2634. audit_groups?: string[]
  2635. endpoints?: PromptAuditEndpointAPI[]
  2636. + config_version?: number
  2637. + updated_at?: string
  2638. + change_summary?: string
  2639. }
  2640. export interface PromptAuditConfigState {
  2641. enabled: boolean
  2642. + blockingEnabled: boolean
  2643. storePassEvents: boolean
  2644. strategy: string
  2645. workerCount: string
  2646. @@ -101,6 +106,7 @@ export interface PromptAuditConfigState {
  2647. auditGroupMode: PromptAuditGroupMode
  2648. auditGroups: string[]
  2649. endpoints: PromptAuditEndpointState[]
  2650. + configVersion: number
  2651. }
  2652. export interface PromptAuditEndpointSavePayload {
  2653. @@ -119,6 +125,7 @@ export interface PromptAuditEndpointSavePayload {
  2654. export interface PromptAuditConfigSavePayload {
  2655. enabled: boolean
  2656. + blocking_enabled: boolean
  2657. store_pass_events: boolean
  2658. strategy: string
  2659. worker_count: number
  2660. @@ -131,6 +138,13 @@ export interface PromptAuditConfigSavePayload {
  2661. export interface PromptAuditRuntime {
  2662. enabled?: boolean
  2663. + blocking_enabled?: boolean
  2664. + effective_mode?: 'off' | 'async_audit' | 'blocking' | string
  2665. + expected_config_version?: number
  2666. + active_config_version?: number
  2667. + config_loaded_at?: string
  2668. + config_load_error?: string
  2669. + config_load_error_at?: string
  2670. process_status?: string
  2671. queue_length?: number
  2672. queue_capacity?: number