| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- package service
- import (
- "strconv"
- "strings"
- "github.com/tidwall/gjson"
- )
- // countGrokNativeSearchCallsFromJSONBytes counts completed native search tool
- // calls in a Responses-style JSON body (output array or nested response.output).
- // Counts: web_search_call, x_search_call, tool_search_call, and function_call
- // named tool_search / web_search / x_search.
- func countGrokNativeSearchCallsFromJSONBytes(body []byte) int {
- if len(body) == 0 || !gjson.ValidBytes(body) {
- return 0
- }
- // Responses envelopes normally expose either top-level output (JSON mode)
- // or response.output (terminal SSE payload). Compatibility layers can retain
- // both copies; counting both would bill the same search twice. Prefer the
- // canonical nested response when present and fall back to top-level output.
- if nested := gjson.GetBytes(body, "response.output"); nested.IsArray() {
- return countGrokNativeSearchCallsInOutputArray(nested)
- }
- return countGrokNativeSearchCallsInOutputArray(gjson.GetBytes(body, "output"))
- }
- func countGrokNativeSearchCallsFromSSEBody(body string) int {
- if strings.TrimSpace(body) == "" {
- return 0
- }
- seen := make(map[string]struct{})
- total := 0
- forEachOpenAISSEDataPayload(body, func(data []byte) {
- total += countGrokNativeSearchCallsInSSEDataDedup(data, seen)
- })
- return total
- }
- // countGrokNativeSearchCallsInSSEData counts search tool calls in one SSE
- // payload without cross-event dedup. Prefer countGrokNativeSearchCallsInSSEDataDedup
- // for live streams so item.done + response.completed do not double-bill.
- func countGrokNativeSearchCallsInSSEData(data []byte) int {
- n, _ := countGrokNativeSearchCallsInSSEDataWithKeys(data)
- return n
- }
- // countGrokNativeSearchCallsInSSEDataDedup increments only unseen call ids.
- // Callers must reuse the same seen map for the full stream lifetime.
- //
- // When call_id/id is missing, a synthetic key is built from item type + name so
- // item.done + response.completed for the same tool still count once (never fall
- // back to raw multi-event n, which ~2× overbills).
- func countGrokNativeSearchCallsInSSEDataDedup(data []byte, seen map[string]struct{}) int {
- if seen == nil {
- return countGrokNativeSearchCallsInSSEData(data)
- }
- n, keys := countGrokNativeSearchCallsInSSEDataWithKeys(data)
- if n <= 0 {
- return 0
- }
- // Prefer stable ids; fill gaps with synthetic keys so we never raw-add n.
- if len(keys) < n {
- // Rebuild keys for every item so unkeyed items still get a fingerprint.
- keys = collectGrokNativeSearchCallKeys(data)
- }
- if len(keys) == 0 {
- // True empty — should not happen when n>0; fail-closed to 0 extra bill.
- return 0
- }
- added := 0
- local := make(map[string]struct{}, len(keys))
- isItemDone := strings.TrimSpace(gjson.GetBytes(data, "type").String()) == "response.output_item.done"
- for _, k := range keys {
- if k == "" {
- continue
- }
- if _, ok := local[k]; ok {
- continue
- }
- local[k] = struct{}{}
- if _, ok := seen[k]; ok {
- if !isItemDone || !strings.HasPrefix(k, "synth:") {
- continue
- }
- // Each id-less item.done is a distinct completed invocation. Advance
- // its ordinal so interrupted streams remain accurately billable.
- separator := strings.LastIndexByte(k, ':')
- if separator < 0 {
- continue
- }
- base := k[:separator]
- for ordinal := 2; ; ordinal++ {
- candidate := base + ":" + strconv.Itoa(ordinal)
- if _, exists := seen[candidate]; !exists {
- k = candidate
- break
- }
- }
- }
- seen[k] = struct{}{}
- added++
- }
- return added
- }
- func collectGrokNativeSearchCallKeys(data []byte) []string {
- if len(data) == 0 || !gjson.ValidBytes(data) {
- return nil
- }
- // An empty type means a bare item object without an SSE envelope; anything
- // else that is not a completion event carries no billable call.
- switch strings.TrimSpace(gjson.GetBytes(data, "type").String()) {
- case "response.output_item.done", "response.completed", "response.done", "":
- default:
- return nil
- }
- var keys []string
- syntheticOrdinals := make(map[string]int)
- consider := func(item gjson.Result) {
- if !isGrokNativeSearchOutputItem(item) {
- return
- }
- key := firstNonEmpty(
- strings.TrimSpace(item.Get("call_id").String()),
- strings.TrimSpace(item.Get("id").String()),
- strings.TrimSpace(item.Get("item.call_id").String()),
- strings.TrimSpace(item.Get("item.id").String()),
- )
- if key == "" {
- // Include the ordinal among same-kind calls. A plain type:name key
- // collapses two id-less web searches in one completed response into
- // one charge. The ordinal remains stable between ordered item.done
- // events and response.completed output.
- base := "synth:" + strings.ToLower(strings.TrimSpace(item.Get("type").String())) +
- ":" + strings.ToLower(strings.TrimSpace(item.Get("name").String()))
- syntheticOrdinals[base]++
- key = base + ":" + strconv.Itoa(syntheticOrdinals[base])
- }
- keys = append(keys, key)
- }
- if item := gjson.GetBytes(data, "item"); item.Exists() {
- consider(item)
- }
- gjson.GetBytes(data, "response.output").ForEach(func(_, item gjson.Result) bool {
- consider(item)
- return true
- })
- gjson.GetBytes(data, "output").ForEach(func(_, item gjson.Result) bool {
- consider(item)
- return true
- })
- if len(keys) == 0 && isGrokNativeSearchOutputItem(gjson.ParseBytes(data)) {
- consider(gjson.ParseBytes(data))
- }
- return keys
- }
- func countGrokNativeSearchCallsInSSEDataWithKeys(data []byte) (int, []string) {
- if len(data) == 0 || !gjson.ValidBytes(data) {
- return 0, nil
- }
- // Count once on item completion / completed response, not on every delta.
- // An empty type is a bare item object without an SSE envelope.
- switch strings.TrimSpace(gjson.GetBytes(data, "type").String()) {
- case "response.output_item.done", "response.completed", "response.done", "":
- default:
- return 0, nil
- }
- var keys []string
- n := 0
- consider := func(item gjson.Result) {
- if !isGrokNativeSearchOutputItem(item) {
- return
- }
- n++
- key := firstNonEmpty(
- strings.TrimSpace(item.Get("call_id").String()),
- strings.TrimSpace(item.Get("id").String()),
- strings.TrimSpace(item.Get("item.call_id").String()),
- strings.TrimSpace(item.Get("item.id").String()),
- )
- if key != "" {
- keys = append(keys, key)
- }
- }
- if item := gjson.GetBytes(data, "item"); item.Exists() {
- consider(item)
- }
- gjson.GetBytes(data, "response.output").ForEach(func(_, item gjson.Result) bool {
- consider(item)
- return true
- })
- gjson.GetBytes(data, "output").ForEach(func(_, item gjson.Result) bool {
- consider(item)
- return true
- })
- // Bare output item event without nested item key.
- if n == 0 && isGrokNativeSearchOutputItem(gjson.ParseBytes(data)) {
- consider(gjson.ParseBytes(data))
- }
- return n, keys
- }
- func countGrokNativeSearchCallsInOutputArray(output gjson.Result) int {
- if !output.IsArray() {
- return 0
- }
- count := 0
- output.ForEach(func(_, item gjson.Result) bool {
- if isGrokNativeSearchOutputItem(item) {
- count++
- }
- return true
- })
- return count
- }
- func isGrokNativeSearchOutputItem(item gjson.Result) bool {
- if !item.Exists() {
- return false
- }
- itemType := strings.ToLower(strings.TrimSpace(item.Get("type").String()))
- switch itemType {
- case "web_search_call", "x_search_call", "tool_search_call":
- return true
- case "function_call", "custom_tool_call":
- name := strings.ToLower(strings.TrimSpace(item.Get("name").String()))
- return name == "web_search" || name == "x_search" || name == "tool_search"
- default:
- return false
- }
- }
|