openai_content_session_seed.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. package service
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "strings"
  6. "github.com/tidwall/gjson"
  7. )
  8. // contentSessionSeedPrefix prevents collisions between content-derived seeds
  9. // and explicit session IDs (e.g. "sess-xxx" or "compat_cc_xxx").
  10. const contentSessionSeedPrefix = "compat_cs_"
  11. // contentStablePrefixSessionSeedPrefix distinguishes cache identities derived
  12. // only from request fields that remain stable across independent prompts.
  13. const contentStablePrefixSessionSeedPrefix = "compat_csp_"
  14. // deriveOpenAIContentSessionSeed builds a stable session seed from an
  15. // OpenAI-format request body. Only fields constant across conversation turns
  16. // are included: model, tools/functions definitions, the leading system/developer
  17. // prompt prefix in Chat messages, instructions (Responses API), and the first
  18. // user message.
  19. // Supports both Chat Completions (messages) and Responses API (input).
  20. func deriveOpenAIContentSessionSeed(body []byte) string {
  21. if len(body) == 0 {
  22. return ""
  23. }
  24. const (
  25. modelField = iota
  26. toolsField
  27. functionsField
  28. instructionsField
  29. messagesField
  30. inputField
  31. contentSessionSeedFieldCount
  32. allContentSessionSeedFields = 1<<contentSessionSeedFieldCount - 1
  33. )
  34. var fields [contentSessionSeedFieldCount]gjson.Result
  35. var seen uint8
  36. // Match gjson.GetBytes by starting at the first root container, even when
  37. // malformed input has a non-JSON prefix.
  38. root := body
  39. for i := 0; i < len(body); i++ {
  40. switch body[i] {
  41. case '{':
  42. root = body[i:]
  43. goto scanRoot
  44. case '[':
  45. return ""
  46. }
  47. }
  48. return ""
  49. scanRoot:
  50. nextKeyOffset := 1
  51. parseRawJSONView(root).ForEach(func(key, value gjson.Result) bool {
  52. if key.Index < nextKeyOffset || key.Index > len(root) {
  53. return false
  54. }
  55. // Result.ForEach can continue after the root '}' on malformed input.
  56. // The separator range excludes braces inside the preceding parsed value.
  57. if bytes.IndexByte(root[nextKeyOffset:key.Index], '}') >= 0 {
  58. return false
  59. }
  60. nextKeyOffset = value.Index + len(value.Raw)
  61. field := -1
  62. switch key.Str {
  63. case "model":
  64. field = modelField
  65. case "tools":
  66. field = toolsField
  67. case "functions":
  68. field = functionsField
  69. case "instructions":
  70. field = instructionsField
  71. case "messages":
  72. field = messagesField
  73. case "input":
  74. field = inputField
  75. }
  76. if field < 0 {
  77. return true
  78. }
  79. mask := uint8(1 << field)
  80. if seen&mask == 0 {
  81. fields[field] = value
  82. seen |= mask
  83. }
  84. return seen != allContentSessionSeedFields
  85. })
  86. var b strings.Builder
  87. if model := fields[modelField].String(); model != "" {
  88. _, _ = b.WriteString("model=")
  89. _, _ = b.WriteString(model)
  90. }
  91. if tools := fields[toolsField]; tools.Exists() && tools.IsArray() && tools.Raw != "[]" {
  92. _, _ = b.WriteString("|tools=")
  93. _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(tools.Raw)))
  94. }
  95. if funcs := fields[functionsField]; funcs.Exists() && funcs.IsArray() && funcs.Raw != "[]" {
  96. _, _ = b.WriteString("|functions=")
  97. _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(funcs.Raw)))
  98. }
  99. if instr := fields[instructionsField].String(); instr != "" {
  100. _, _ = b.WriteString("|instructions=")
  101. _, _ = b.WriteString(instr)
  102. }
  103. firstUserCaptured := false
  104. msgs := fields[messagesField]
  105. if msgs.Exists() && msgs.IsArray() {
  106. systemPrefixOpen := true
  107. msgs.ForEach(func(_, msg gjson.Result) bool {
  108. role := msg.Get("role").String()
  109. switch role {
  110. case "system", "developer":
  111. if systemPrefixOpen {
  112. _, _ = b.WriteString("|system=")
  113. if c := msg.Get("content"); c.Exists() {
  114. _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw)))
  115. }
  116. }
  117. case "user":
  118. systemPrefixOpen = false
  119. if !firstUserCaptured {
  120. _, _ = b.WriteString("|first_user=")
  121. if c := msg.Get("content"); c.Exists() {
  122. _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw)))
  123. }
  124. firstUserCaptured = true
  125. }
  126. default:
  127. systemPrefixOpen = false
  128. }
  129. return true
  130. })
  131. } else if inp := fields[inputField]; inp.Exists() {
  132. if inp.Type == gjson.String {
  133. _, _ = b.WriteString("|input=")
  134. _, _ = b.WriteString(inp.String())
  135. } else if inp.IsArray() {
  136. inp.ForEach(func(_, item gjson.Result) bool {
  137. role := item.Get("role").String()
  138. switch role {
  139. case "system", "developer":
  140. _, _ = b.WriteString("|system=")
  141. if c := item.Get("content"); c.Exists() {
  142. _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw)))
  143. }
  144. case "user":
  145. if !firstUserCaptured {
  146. _, _ = b.WriteString("|first_user=")
  147. if c := item.Get("content"); c.Exists() {
  148. _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw)))
  149. }
  150. firstUserCaptured = true
  151. }
  152. }
  153. if !firstUserCaptured && item.Get("type").String() == "input_text" {
  154. _, _ = b.WriteString("|first_user=")
  155. if text := item.Get("text").String(); text != "" {
  156. _, _ = b.WriteString(text)
  157. }
  158. firstUserCaptured = true
  159. }
  160. return true
  161. })
  162. }
  163. }
  164. if b.Len() == 0 {
  165. return ""
  166. }
  167. return contentSessionSeedPrefix + b.String()
  168. }
  169. // deriveOpenAIAnchoredContentSessionSeed returns the legacy content-derived
  170. // seed only when it contains a meaningful user/input anchor. This preserves
  171. // the existing session derivation while preventing model-only requests from
  172. // becoming a tenant-wide cache routing identity.
  173. func deriveOpenAIAnchoredContentSessionSeed(body []byte) string {
  174. if !hasOpenAIContentSessionUserAnchor(body) {
  175. return ""
  176. }
  177. return deriveOpenAIContentSessionSeed(body)
  178. }
  179. func hasOpenAIContentSessionUserAnchor(body []byte) bool {
  180. if len(body) == 0 {
  181. return false
  182. }
  183. if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
  184. anchored := false
  185. messages.ForEach(func(_, message gjson.Result) bool {
  186. if strings.TrimSpace(message.Get("role").String()) != "user" {
  187. return true
  188. }
  189. anchored = hasMeaningfulOpenAIContent(message.Get("content"))
  190. return false
  191. })
  192. return anchored
  193. }
  194. input := gjson.GetBytes(body, "input")
  195. if !input.Exists() {
  196. return false
  197. }
  198. if input.Type == gjson.String {
  199. return strings.TrimSpace(input.String()) != ""
  200. }
  201. if !input.IsArray() {
  202. return false
  203. }
  204. anchored := false
  205. input.ForEach(func(_, item gjson.Result) bool {
  206. if strings.TrimSpace(item.Get("role").String()) == "user" {
  207. anchored = hasMeaningfulOpenAIContent(item.Get("content"))
  208. return false
  209. }
  210. if strings.TrimSpace(item.Get("type").String()) == "input_text" {
  211. anchored = strings.TrimSpace(item.Get("text").String()) != ""
  212. return false
  213. }
  214. return true
  215. })
  216. return anchored
  217. }
  218. func hasMeaningfulOpenAIContent(content gjson.Result) bool {
  219. if !content.Exists() || content.Type == gjson.Null {
  220. return false
  221. }
  222. if content.Type == gjson.String {
  223. return strings.TrimSpace(content.String()) != ""
  224. }
  225. if !content.IsArray() {
  226. normalized, ok := normalizeNonEmptyCompatSeedJSON(content)
  227. return ok && strings.TrimSpace(normalized) != ""
  228. }
  229. meaningful := false
  230. content.ForEach(func(_, item gjson.Result) bool {
  231. if item.Type == gjson.String {
  232. meaningful = strings.TrimSpace(item.String()) != ""
  233. } else if text := item.Get("text"); text.Exists() {
  234. meaningful = strings.TrimSpace(text.String()) != ""
  235. } else {
  236. _, meaningful = normalizeNonEmptyCompatSeedJSON(item)
  237. }
  238. return !meaningful
  239. })
  240. return meaningful
  241. }
  242. // deriveOpenAIStablePrefixSessionSeed builds a seed from the reusable prefix
  243. // of an OpenAI-format request. User and assistant content are deliberately
  244. // excluded so independent prompts with the same system/tool prefix can share
  245. // an upstream prompt-cache routing identity.
  246. //
  247. // An empty result means the request has no meaningful stable prefix. Callers
  248. // must then use a narrower fallback instead of grouping all requests by tenant
  249. // and model alone.
  250. func deriveOpenAIStablePrefixSessionSeed(body []byte) string {
  251. if len(body) == 0 {
  252. return ""
  253. }
  254. var b strings.Builder
  255. hasStablePrefix := false
  256. appendJSON := func(label string, value gjson.Result) {
  257. normalized, ok := normalizeNonEmptyCompatSeedJSON(value)
  258. if !ok {
  259. return
  260. }
  261. _, _ = b.WriteString("|")
  262. _, _ = b.WriteString(label)
  263. _, _ = b.WriteString("=")
  264. _, _ = b.WriteString(normalized)
  265. hasStablePrefix = true
  266. }
  267. if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() {
  268. appendJSON("tools", tools)
  269. }
  270. if funcs := gjson.GetBytes(body, "functions"); funcs.Exists() && funcs.IsArray() {
  271. appendJSON("functions", funcs)
  272. }
  273. if instructions := gjson.GetBytes(body, "instructions"); strings.TrimSpace(instructions.String()) != "" {
  274. appendJSON("instructions", instructions)
  275. }
  276. appendSystemMessages := func(items gjson.Result) {
  277. items.ForEach(func(_, item gjson.Result) bool {
  278. role := strings.TrimSpace(item.Get("role").String())
  279. switch role {
  280. case "system", "developer":
  281. appendJSON(role, item.Get("content"))
  282. }
  283. return true
  284. })
  285. }
  286. if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
  287. appendSystemMessages(messages)
  288. } else if input := gjson.GetBytes(body, "input"); input.Exists() && input.IsArray() {
  289. appendSystemMessages(input)
  290. }
  291. if !hasStablePrefix {
  292. return ""
  293. }
  294. return contentStablePrefixSessionSeedPrefix + b.String()
  295. }
  296. func normalizeNonEmptyCompatSeedJSON(value gjson.Result) (string, bool) {
  297. if !value.Exists() || value.Type == gjson.Null {
  298. return "", false
  299. }
  300. normalized := normalizeCompatSeedJSON(json.RawMessage(value.Raw))
  301. switch normalized {
  302. case "", `""`, "[]", "{}", "null":
  303. return "", false
  304. default:
  305. return normalized, true
  306. }
  307. }