Files
sub2api/backend/internal/service/grok_search_count.go
T
IanShaw027 07b46e93e4 fix(grok): 修正 voice 路由推导、视频价归一化与门禁缓存驻留
- custom-voices endpoint 改由匹配到的路由模板推导(c.FullPath())。
  原实现按请求 URL 字面后缀判 /audio,voice_id 恰为 "audio" 时
  GET /custom-voices/audio 会被改写成 custom-voices/audio/audio,
  把档案查询变成音频下载。补 voice_id="audio" 的路由推导测试。

- NormalizeVideoModelPrices 不再把无法识别的分辨率静默折算成 480p:
  新增 LookupVideoBillingResolution 报告未知档位,配置解析路径丢弃并告警,
  运行时计费仍走 OrDefault 兜底。model/tier 两层遍历改为排序遍历,
  多个别名收敛到同一 family 时结果不再随 Go map 顺序漂移,冲突单价告警。

- grok free quota 软门禁缓存新增过期淘汰。条目按 account_id 键控且只写不删,
  账号下线后会驻留至进程结束;淘汰只挂在已受 TTL 约束的查询路径上,
  不影响缓存命中热路径。

- 迁移 220 清空非 Grok 分组视频价前先落快照表 groups_video_price_backup_220,
  原 UPDATE 不可回滚。

- 简化 grok_search_count 两处等价冗余的事件类型分支。
2026-08-08 11:02:22 +08:00

234 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}