Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
234 lines
7.3 KiB
Go
234 lines
7.3 KiB
Go
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
|
||
}
|
||
}
|