grok_search_count.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. package service
  2. import (
  3. "strconv"
  4. "strings"
  5. "github.com/tidwall/gjson"
  6. )
  7. // countGrokNativeSearchCallsFromJSONBytes counts completed native search tool
  8. // calls in a Responses-style JSON body (output array or nested response.output).
  9. // Counts: web_search_call, x_search_call, tool_search_call, and function_call
  10. // named tool_search / web_search / x_search.
  11. func countGrokNativeSearchCallsFromJSONBytes(body []byte) int {
  12. if len(body) == 0 || !gjson.ValidBytes(body) {
  13. return 0
  14. }
  15. // Responses envelopes normally expose either top-level output (JSON mode)
  16. // or response.output (terminal SSE payload). Compatibility layers can retain
  17. // both copies; counting both would bill the same search twice. Prefer the
  18. // canonical nested response when present and fall back to top-level output.
  19. if nested := gjson.GetBytes(body, "response.output"); nested.IsArray() {
  20. return countGrokNativeSearchCallsInOutputArray(nested)
  21. }
  22. return countGrokNativeSearchCallsInOutputArray(gjson.GetBytes(body, "output"))
  23. }
  24. func countGrokNativeSearchCallsFromSSEBody(body string) int {
  25. if strings.TrimSpace(body) == "" {
  26. return 0
  27. }
  28. seen := make(map[string]struct{})
  29. total := 0
  30. forEachOpenAISSEDataPayload(body, func(data []byte) {
  31. total += countGrokNativeSearchCallsInSSEDataDedup(data, seen)
  32. })
  33. return total
  34. }
  35. // countGrokNativeSearchCallsInSSEData counts search tool calls in one SSE
  36. // payload without cross-event dedup. Prefer countGrokNativeSearchCallsInSSEDataDedup
  37. // for live streams so item.done + response.completed do not double-bill.
  38. func countGrokNativeSearchCallsInSSEData(data []byte) int {
  39. n, _ := countGrokNativeSearchCallsInSSEDataWithKeys(data)
  40. return n
  41. }
  42. // countGrokNativeSearchCallsInSSEDataDedup increments only unseen call ids.
  43. // Callers must reuse the same seen map for the full stream lifetime.
  44. //
  45. // When call_id/id is missing, a synthetic key is built from item type + name so
  46. // item.done + response.completed for the same tool still count once (never fall
  47. // back to raw multi-event n, which ~2× overbills).
  48. func countGrokNativeSearchCallsInSSEDataDedup(data []byte, seen map[string]struct{}) int {
  49. if seen == nil {
  50. return countGrokNativeSearchCallsInSSEData(data)
  51. }
  52. n, keys := countGrokNativeSearchCallsInSSEDataWithKeys(data)
  53. if n <= 0 {
  54. return 0
  55. }
  56. // Prefer stable ids; fill gaps with synthetic keys so we never raw-add n.
  57. if len(keys) < n {
  58. // Rebuild keys for every item so unkeyed items still get a fingerprint.
  59. keys = collectGrokNativeSearchCallKeys(data)
  60. }
  61. if len(keys) == 0 {
  62. // True empty — should not happen when n>0; fail-closed to 0 extra bill.
  63. return 0
  64. }
  65. added := 0
  66. local := make(map[string]struct{}, len(keys))
  67. isItemDone := strings.TrimSpace(gjson.GetBytes(data, "type").String()) == "response.output_item.done"
  68. for _, k := range keys {
  69. if k == "" {
  70. continue
  71. }
  72. if _, ok := local[k]; ok {
  73. continue
  74. }
  75. local[k] = struct{}{}
  76. if _, ok := seen[k]; ok {
  77. if !isItemDone || !strings.HasPrefix(k, "synth:") {
  78. continue
  79. }
  80. // Each id-less item.done is a distinct completed invocation. Advance
  81. // its ordinal so interrupted streams remain accurately billable.
  82. separator := strings.LastIndexByte(k, ':')
  83. if separator < 0 {
  84. continue
  85. }
  86. base := k[:separator]
  87. for ordinal := 2; ; ordinal++ {
  88. candidate := base + ":" + strconv.Itoa(ordinal)
  89. if _, exists := seen[candidate]; !exists {
  90. k = candidate
  91. break
  92. }
  93. }
  94. }
  95. seen[k] = struct{}{}
  96. added++
  97. }
  98. return added
  99. }
  100. func collectGrokNativeSearchCallKeys(data []byte) []string {
  101. if len(data) == 0 || !gjson.ValidBytes(data) {
  102. return nil
  103. }
  104. // An empty type means a bare item object without an SSE envelope; anything
  105. // else that is not a completion event carries no billable call.
  106. switch strings.TrimSpace(gjson.GetBytes(data, "type").String()) {
  107. case "response.output_item.done", "response.completed", "response.done", "":
  108. default:
  109. return nil
  110. }
  111. var keys []string
  112. syntheticOrdinals := make(map[string]int)
  113. consider := func(item gjson.Result) {
  114. if !isGrokNativeSearchOutputItem(item) {
  115. return
  116. }
  117. key := firstNonEmpty(
  118. strings.TrimSpace(item.Get("call_id").String()),
  119. strings.TrimSpace(item.Get("id").String()),
  120. strings.TrimSpace(item.Get("item.call_id").String()),
  121. strings.TrimSpace(item.Get("item.id").String()),
  122. )
  123. if key == "" {
  124. // Include the ordinal among same-kind calls. A plain type:name key
  125. // collapses two id-less web searches in one completed response into
  126. // one charge. The ordinal remains stable between ordered item.done
  127. // events and response.completed output.
  128. base := "synth:" + strings.ToLower(strings.TrimSpace(item.Get("type").String())) +
  129. ":" + strings.ToLower(strings.TrimSpace(item.Get("name").String()))
  130. syntheticOrdinals[base]++
  131. key = base + ":" + strconv.Itoa(syntheticOrdinals[base])
  132. }
  133. keys = append(keys, key)
  134. }
  135. if item := gjson.GetBytes(data, "item"); item.Exists() {
  136. consider(item)
  137. }
  138. gjson.GetBytes(data, "response.output").ForEach(func(_, item gjson.Result) bool {
  139. consider(item)
  140. return true
  141. })
  142. gjson.GetBytes(data, "output").ForEach(func(_, item gjson.Result) bool {
  143. consider(item)
  144. return true
  145. })
  146. if len(keys) == 0 && isGrokNativeSearchOutputItem(gjson.ParseBytes(data)) {
  147. consider(gjson.ParseBytes(data))
  148. }
  149. return keys
  150. }
  151. func countGrokNativeSearchCallsInSSEDataWithKeys(data []byte) (int, []string) {
  152. if len(data) == 0 || !gjson.ValidBytes(data) {
  153. return 0, nil
  154. }
  155. // Count once on item completion / completed response, not on every delta.
  156. // An empty type is a bare item object without an SSE envelope.
  157. switch strings.TrimSpace(gjson.GetBytes(data, "type").String()) {
  158. case "response.output_item.done", "response.completed", "response.done", "":
  159. default:
  160. return 0, nil
  161. }
  162. var keys []string
  163. n := 0
  164. consider := func(item gjson.Result) {
  165. if !isGrokNativeSearchOutputItem(item) {
  166. return
  167. }
  168. n++
  169. key := firstNonEmpty(
  170. strings.TrimSpace(item.Get("call_id").String()),
  171. strings.TrimSpace(item.Get("id").String()),
  172. strings.TrimSpace(item.Get("item.call_id").String()),
  173. strings.TrimSpace(item.Get("item.id").String()),
  174. )
  175. if key != "" {
  176. keys = append(keys, key)
  177. }
  178. }
  179. if item := gjson.GetBytes(data, "item"); item.Exists() {
  180. consider(item)
  181. }
  182. gjson.GetBytes(data, "response.output").ForEach(func(_, item gjson.Result) bool {
  183. consider(item)
  184. return true
  185. })
  186. gjson.GetBytes(data, "output").ForEach(func(_, item gjson.Result) bool {
  187. consider(item)
  188. return true
  189. })
  190. // Bare output item event without nested item key.
  191. if n == 0 && isGrokNativeSearchOutputItem(gjson.ParseBytes(data)) {
  192. consider(gjson.ParseBytes(data))
  193. }
  194. return n, keys
  195. }
  196. func countGrokNativeSearchCallsInOutputArray(output gjson.Result) int {
  197. if !output.IsArray() {
  198. return 0
  199. }
  200. count := 0
  201. output.ForEach(func(_, item gjson.Result) bool {
  202. if isGrokNativeSearchOutputItem(item) {
  203. count++
  204. }
  205. return true
  206. })
  207. return count
  208. }
  209. func isGrokNativeSearchOutputItem(item gjson.Result) bool {
  210. if !item.Exists() {
  211. return false
  212. }
  213. itemType := strings.ToLower(strings.TrimSpace(item.Get("type").String()))
  214. switch itemType {
  215. case "web_search_call", "x_search_call", "tool_search_call":
  216. return true
  217. case "function_call", "custom_tool_call":
  218. name := strings.ToLower(strings.TrimSpace(item.Get("name").String()))
  219. return name == "web_search" || name == "x_search" || name == "tool_search"
  220. default:
  221. return false
  222. }
  223. }