openai_encrypted_content_lineage.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. package service
  2. import (
  3. "crypto/sha256"
  4. "encoding/hex"
  5. "encoding/json"
  6. "strings"
  7. "github.com/gin-gonic/gin"
  8. "github.com/tidwall/gjson"
  9. )
  10. // invalid_encrypted_content 失效密文 lineage。
  11. //
  12. // 上游判定某轮请求中的加密 reasoning/compaction 项不可解后,同一失效密文会随
  13. // 客户端维护的会话历史在后续每一轮重新出现,重复触发"整包被拒→剥离→重试/
  14. // 重连"。这里按会话记录已被上游拒绝过的 encrypted_content 摘要
  15. // (OpenAIWSStateStore,带 TTL 与容量自保护);后续请求进场时仅剥离摘要命中
  16. // 的项,新生成的密文摘要不同,不会被误删。
  17. const openAIWSFallbackReasonInvalidEncryptedContent = "invalid_encrypted_content"
  18. // openAIWSIngressSessionHashContextKey 在 gin context 中携带 ingress 会话哈希,
  19. // 供 HTTP bridge turn 内的 lineage 记录复用同一会话键。
  20. const openAIWSIngressSessionHashContextKey = "openai_ws_ingress_session_hash"
  21. func openAIEncryptedContentDigest(encrypted string) string {
  22. sum := sha256.Sum256([]byte(encrypted))
  23. return hex.EncodeToString(sum[:])
  24. }
  25. // openAIEncryptedLineageItemType 限定 lineage 覆盖的项类型,与剥离端
  26. // sanitizeEncryptedReasoningInputItem 能处理的类型对称;其他类型即使携带
  27. // encrypted_content 也不收摘要,避免记入永远剥不掉的摘要后每轮空转解码。
  28. func openAIEncryptedLineageItemType(itemType string) bool {
  29. switch strings.TrimSpace(itemType) {
  30. case "reasoning", "compaction", "compaction_summary":
  31. return true
  32. default:
  33. return false
  34. }
  35. }
  36. // collectOpenAIEncryptedContentDigestsRaw 收集 input 中 lineage 覆盖类型所携
  37. // 密文的摘要。payload 须遵守 replay 所有权不变式(零拷贝视图解析)。
  38. func collectOpenAIEncryptedContentDigestsRaw(payload []byte) []string {
  39. if len(payload) == 0 {
  40. return nil
  41. }
  42. input := gjson.Get(openAIWSPayloadStringView(payload), "input")
  43. if !input.Exists() {
  44. return nil
  45. }
  46. var digests []string
  47. appendItem := func(item gjson.Result) {
  48. if !openAIEncryptedLineageItemType(item.Get("type").String()) {
  49. return
  50. }
  51. encrypted := item.Get("encrypted_content")
  52. if encrypted.Type == gjson.String && encrypted.String() != "" {
  53. digests = append(digests, openAIEncryptedContentDigest(encrypted.String()))
  54. }
  55. }
  56. if input.IsArray() {
  57. input.ForEach(func(_, item gjson.Result) bool {
  58. appendItem(item)
  59. return true
  60. })
  61. return digests
  62. }
  63. if input.IsObject() {
  64. appendItem(input)
  65. }
  66. return digests
  67. }
  68. // stripOpenAIInvalidEncryptedContentItems 剥离 input 中摘要命中 invalid 的密文
  69. // 项,剥离语义与 trimOpenAIEncryptedReasoningItems 对齐:reasoning 仅剥
  70. // encrypted_content(连带 content:null 清理与空骨架删除),compaction/
  71. // compaction_summary 整项删除。返回被改写的项数。
  72. func stripOpenAIInvalidEncryptedContentItems(reqBody map[string]any, invalid map[string]struct{}) int {
  73. if len(reqBody) == 0 || len(invalid) == 0 {
  74. return 0
  75. }
  76. inputValue, has := reqBody["input"]
  77. if !has {
  78. return 0
  79. }
  80. stripped := 0
  81. stripItem := func(item any) (next any, changed bool, keep bool) {
  82. inputItem, ok := item.(map[string]any)
  83. if !ok {
  84. return item, false, true
  85. }
  86. encrypted, ok := inputItem["encrypted_content"].(string)
  87. if !ok || encrypted == "" {
  88. return item, false, true
  89. }
  90. if _, hit := invalid[openAIEncryptedContentDigest(encrypted)]; !hit {
  91. return item, false, true
  92. }
  93. return sanitizeEncryptedReasoningInputItem(item)
  94. }
  95. switch input := inputValue.(type) {
  96. case []any:
  97. filtered := input[:0]
  98. for _, item := range input {
  99. nextItem, changed, keep := stripItem(item)
  100. if changed {
  101. stripped++
  102. }
  103. if !keep {
  104. continue
  105. }
  106. filtered = append(filtered, nextItem)
  107. }
  108. if stripped == 0 {
  109. return 0
  110. }
  111. if len(filtered) == 0 {
  112. delete(reqBody, "input")
  113. return stripped
  114. }
  115. reqBody["input"] = filtered
  116. return stripped
  117. case map[string]any:
  118. nextItem, changed, keep := stripItem(input)
  119. if !changed {
  120. return 0
  121. }
  122. if !keep {
  123. delete(reqBody, "input")
  124. return 1
  125. }
  126. nextMap, ok := nextItem.(map[string]any)
  127. if !ok {
  128. return 0
  129. }
  130. reqBody["input"] = nextMap
  131. return 1
  132. default:
  133. return 0
  134. }
  135. }
  136. // openAIRawPayloadHasInvalidEncryptedContent 探测 raw payload 是否携带命中
  137. // invalid 摘要的密文项;零命中的常态路径不做任何改写。payload 须遵守 replay
  138. // 所有权不变式(零拷贝视图解析)。
  139. func openAIRawPayloadHasInvalidEncryptedContent(payload []byte, invalid map[string]struct{}) bool {
  140. if len(payload) == 0 || len(invalid) == 0 {
  141. return false
  142. }
  143. input := gjson.Get(openAIWSPayloadStringView(payload), "input")
  144. if !input.Exists() {
  145. return false
  146. }
  147. hit := false
  148. checkItem := func(item gjson.Result) bool {
  149. encrypted := item.Get("encrypted_content")
  150. if encrypted.Type != gjson.String || encrypted.String() == "" {
  151. return false
  152. }
  153. _, matched := invalid[openAIEncryptedContentDigest(encrypted.String())]
  154. return matched
  155. }
  156. if input.IsArray() {
  157. input.ForEach(func(_, item gjson.Result) bool {
  158. if checkItem(item) {
  159. hit = true
  160. return false
  161. }
  162. return true
  163. })
  164. return hit
  165. }
  166. if input.IsObject() {
  167. return checkItem(input)
  168. }
  169. return false
  170. }
  171. // stripOpenAIInvalidEncryptedContentFromReplayItems 对 replay 历史序列做同款
  172. // 剥离。有改动时返回新头数组,命中项以重写后的新正文整体替换(遵守 replay
  173. // 所有权不变式,原正文不被修改);未命中时原样返回。
  174. func stripOpenAIInvalidEncryptedContentFromReplayItems(items []json.RawMessage, invalid map[string]struct{}) ([]json.RawMessage, int) {
  175. if len(items) == 0 || len(invalid) == 0 {
  176. return items, 0
  177. }
  178. hit := false
  179. for _, item := range items {
  180. encrypted := gjson.Get(openAIWSPayloadStringView(item), "encrypted_content")
  181. if encrypted.Type != gjson.String || encrypted.String() == "" {
  182. continue
  183. }
  184. if _, matched := invalid[openAIEncryptedContentDigest(encrypted.String())]; matched {
  185. hit = true
  186. break
  187. }
  188. }
  189. if !hit {
  190. return items, 0
  191. }
  192. stripped := 0
  193. next := make([]json.RawMessage, 0, len(items))
  194. for _, item := range items {
  195. var decoded map[string]any
  196. if err := decodeOpenAIJSONUseNumber(item, &decoded); err != nil {
  197. next = append(next, item)
  198. continue
  199. }
  200. encrypted, ok := decoded["encrypted_content"].(string)
  201. if !ok || encrypted == "" {
  202. next = append(next, item)
  203. continue
  204. }
  205. if _, matched := invalid[openAIEncryptedContentDigest(encrypted)]; !matched {
  206. next = append(next, item)
  207. continue
  208. }
  209. nextItem, changed, keep := sanitizeEncryptedReasoningInputItem(decoded)
  210. if !changed {
  211. next = append(next, item)
  212. continue
  213. }
  214. stripped++
  215. if !keep {
  216. continue
  217. }
  218. rebuilt, err := marshalOpenAIUpstreamJSON(nextItem)
  219. if err != nil {
  220. next = append(next, item)
  221. stripped--
  222. continue
  223. }
  224. next = append(next, json.RawMessage(rebuilt))
  225. }
  226. if stripped == 0 {
  227. return items, 0
  228. }
  229. return next, stripped
  230. }
  231. // stripOpenAIInvalidEncryptedContentRaw 是 raw JSON 版剥离:先零成本探测,命中
  232. // 才解码改写重编码。返回改写后的 payload 与剥离项数;未命中时原样返回。
  233. func stripOpenAIInvalidEncryptedContentRaw(payload []byte, invalid map[string]struct{}) ([]byte, int, error) {
  234. if !openAIRawPayloadHasInvalidEncryptedContent(payload, invalid) {
  235. return payload, 0, nil
  236. }
  237. var decoded map[string]any
  238. if err := decodeOpenAIJSONUseNumber(payload, &decoded); err != nil {
  239. return payload, 0, err
  240. }
  241. stripped := stripOpenAIInvalidEncryptedContentItems(decoded, invalid)
  242. if stripped == 0 {
  243. return payload, 0, nil
  244. }
  245. rebuilt, err := marshalOpenAIUpstreamJSON(decoded)
  246. if err != nil {
  247. return payload, 0, err
  248. }
  249. return rebuilt, stripped, nil
  250. }
  251. // markOpenAIWSInvalidEncryptedContentLineage 把本次被上游拒绝的密文摘要写入
  252. // 会话 lineage。digests 须在剥离前收集。
  253. func (s *OpenAIGatewayService) markOpenAIWSInvalidEncryptedContentLineage(groupID int64, sessionHash string, digests []string) {
  254. if s == nil || len(digests) == 0 || strings.TrimSpace(sessionHash) == "" {
  255. return
  256. }
  257. stateStore := s.getOpenAIWSStateStore()
  258. if stateStore == nil {
  259. return
  260. }
  261. stateStore.MarkSessionInvalidEncryptedContent(groupID, sessionHash, digests, s.openAIWSSessionStickyTTL())
  262. }
  263. // sessionInvalidEncryptedContentDigests 返回会话已知失效密文摘要;全局无记录
  264. // 时(常态)零成本返回 nil。
  265. func (s *OpenAIGatewayService) sessionInvalidEncryptedContentDigests(groupID int64, sessionHash string) map[string]struct{} {
  266. if s == nil || strings.TrimSpace(sessionHash) == "" {
  267. return nil
  268. }
  269. stateStore := s.getOpenAIWSStateStore()
  270. if stateStore == nil || !stateStore.HasAnySessionInvalidEncryptedContent() {
  271. return nil
  272. }
  273. return stateStore.GetSessionInvalidEncryptedContentDigests(groupID, sessionHash)
  274. }
  275. // openAIWSLineageSessionHashFromContext 取 lineage 会话键:优先 ingress 循环
  276. // 写入的会话哈希(与读取侧同键),否则按请求体派生。
  277. func (s *OpenAIGatewayService) openAIWSLineageSessionHashFromContext(c *gin.Context, body []byte) string {
  278. if c != nil {
  279. if fromCtx := strings.TrimSpace(c.GetString(openAIWSIngressSessionHashContextKey)); fromCtx != "" {
  280. return fromCtx
  281. }
  282. }
  283. return s.GenerateSessionHash(c, body)
  284. }
  285. // markOpenAIWSInvalidEncryptedContentLineageFromPayload 在上游以
  286. // invalid_encrypted_content 拒绝 payload 时记录其密文摘要并输出观测日志。
  287. func (s *OpenAIGatewayService) markOpenAIWSInvalidEncryptedContentLineageFromPayload(
  288. c *gin.Context,
  289. payload []byte,
  290. logKey string,
  291. accountID int64,
  292. turn int,
  293. ) {
  294. digests := collectOpenAIEncryptedContentDigestsRaw(payload)
  295. if len(digests) == 0 {
  296. return
  297. }
  298. s.markOpenAIWSInvalidEncryptedContentLineage(
  299. getOpenAIGroupIDFromContext(c),
  300. s.openAIWSLineageSessionHashFromContext(c, payload),
  301. digests,
  302. )
  303. logOpenAIWSModeInfo("%s account_id=%d turn=%d digests=%d", logKey, accountID, turn, len(digests))
  304. }
  305. // stripSessionInvalidEncryptedContentLogged 对 payload 执行会话失效密文剥离并
  306. // 输出观测日志(logKey / logKey+"_skip"),返回(可能已替换的)payload 与剥离
  307. // 项数;未命中或剥离失败时原样返回。
  308. func (s *OpenAIGatewayService) stripSessionInvalidEncryptedContentLogged(
  309. payload []byte,
  310. invalid map[string]struct{},
  311. logKey string,
  312. accountID int64,
  313. turn int,
  314. ) ([]byte, int) {
  315. strippedPayload, strippedCount, stripErr := stripOpenAIInvalidEncryptedContentRaw(payload, invalid)
  316. if stripErr != nil {
  317. logOpenAIWSModeInfo(
  318. "%s_skip account_id=%d turn=%d reason=strip_error cause=%s",
  319. logKey,
  320. accountID,
  321. turn,
  322. truncateOpenAIWSLogValue(stripErr.Error(), openAIWSLogValueMaxLen),
  323. )
  324. return payload, 0
  325. }
  326. if strippedCount > 0 {
  327. logOpenAIWSModeInfo(
  328. "%s account_id=%d turn=%d stripped_items=%d",
  329. logKey,
  330. accountID,
  331. turn,
  332. strippedCount,
  333. )
  334. }
  335. return strippedPayload, strippedCount
  336. }