openai_tool_continuation.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. package service
  2. import (
  3. "strings"
  4. "github.com/tidwall/gjson"
  5. )
  6. // ToolContinuationSignals 聚合工具续链相关信号,避免重复遍历 input。
  7. type ToolContinuationSignals struct {
  8. HasFunctionCallOutput bool
  9. HasFunctionCallOutputMissingCallID bool
  10. HasToolCallContext bool
  11. HasItemReference bool
  12. HasItemReferenceForAllCallIDs bool
  13. FunctionCallOutputCallIDs []string
  14. }
  15. // FunctionCallOutputValidation 汇总 function_call_output 关联性校验结果。
  16. type FunctionCallOutputValidation struct {
  17. HasFunctionCallOutput bool
  18. HasToolCallContext bool
  19. HasFunctionCallOutputMissingCallID bool
  20. HasItemReferenceForAllCallIDs bool
  21. }
  22. func isCodexToolCallContextItemType(typ string) bool {
  23. switch strings.TrimSpace(typ) {
  24. case "tool_call",
  25. "function_call",
  26. "local_shell_call",
  27. "tool_search_call",
  28. "custom_tool_call",
  29. "mcp_tool_call":
  30. return true
  31. default:
  32. return false
  33. }
  34. }
  35. func isCodexToolCallOutputItemType(typ string) bool {
  36. switch strings.TrimSpace(typ) {
  37. case "function_call_output",
  38. "tool_search_output",
  39. "custom_tool_call_output",
  40. "mcp_tool_call_output":
  41. return true
  42. default:
  43. return false
  44. }
  45. }
  46. // NeedsToolContinuation 判定请求是否需要工具调用续链处理。
  47. // 满足以下任一信号即视为续链:previous_response_id、input 内包含工具输出/item_reference、
  48. // 或显式声明 tools/tool_choice。
  49. func NeedsToolContinuation(reqBody map[string]any) bool {
  50. if reqBody == nil {
  51. return false
  52. }
  53. if hasNonEmptyString(reqBody["previous_response_id"]) {
  54. return true
  55. }
  56. if hasToolsSignal(reqBody) {
  57. return true
  58. }
  59. if hasToolChoiceSignal(reqBody) {
  60. return true
  61. }
  62. input, ok := reqBody["input"].([]any)
  63. if !ok {
  64. return false
  65. }
  66. for _, item := range input {
  67. itemMap, ok := item.(map[string]any)
  68. if !ok {
  69. continue
  70. }
  71. itemType, _ := itemMap["type"].(string)
  72. if isCodexToolCallItemType(itemType) || itemType == "item_reference" {
  73. return true
  74. }
  75. }
  76. return false
  77. }
  78. // AnalyzeToolContinuationSignals 单次遍历 input,提取工具输出/工具调用上下文/item_reference 相关信号。
  79. // 字段名保留 FunctionCallOutput 是为了兼容既有调用点;语义覆盖 Codex 的所有工具输出
  80. // (function_call_output/tool_search_output/custom_tool_call_output/mcp_tool_call_output)。
  81. func AnalyzeToolContinuationSignals(reqBody map[string]any) ToolContinuationSignals {
  82. signals := ToolContinuationSignals{}
  83. if reqBody == nil {
  84. return signals
  85. }
  86. input, ok := reqBody["input"].([]any)
  87. if !ok {
  88. return signals
  89. }
  90. var callIDs map[string]struct{}
  91. var referenceIDs map[string]struct{}
  92. for _, item := range input {
  93. itemMap, ok := item.(map[string]any)
  94. if !ok {
  95. continue
  96. }
  97. itemType, _ := itemMap["type"].(string)
  98. switch {
  99. case isCodexToolCallContextItemType(itemType):
  100. callID, _ := itemMap["call_id"].(string)
  101. if strings.TrimSpace(callID) != "" {
  102. signals.HasToolCallContext = true
  103. }
  104. case isCodexToolCallOutputItemType(itemType):
  105. signals.HasFunctionCallOutput = true
  106. callID, _ := itemMap["call_id"].(string)
  107. callID = strings.TrimSpace(callID)
  108. if callID == "" {
  109. signals.HasFunctionCallOutputMissingCallID = true
  110. continue
  111. }
  112. if callIDs == nil {
  113. callIDs = make(map[string]struct{})
  114. }
  115. callIDs[callID] = struct{}{}
  116. case itemType == "item_reference":
  117. signals.HasItemReference = true
  118. idValue, _ := itemMap["id"].(string)
  119. idValue = strings.TrimSpace(idValue)
  120. if idValue == "" {
  121. continue
  122. }
  123. if referenceIDs == nil {
  124. referenceIDs = make(map[string]struct{})
  125. }
  126. referenceIDs[idValue] = struct{}{}
  127. }
  128. }
  129. if len(callIDs) == 0 {
  130. return signals
  131. }
  132. signals.FunctionCallOutputCallIDs = make([]string, 0, len(callIDs))
  133. allReferenced := len(referenceIDs) > 0
  134. for callID := range callIDs {
  135. signals.FunctionCallOutputCallIDs = append(signals.FunctionCallOutputCallIDs, callID)
  136. if allReferenced {
  137. if _, ok := referenceIDs[callID]; !ok {
  138. allReferenced = false
  139. }
  140. }
  141. }
  142. signals.HasItemReferenceForAllCallIDs = allReferenced
  143. return signals
  144. }
  145. // ValidateFunctionCallOutputContextBytes 基于 raw JSON 校验工具输出续链,避免 handler 预校验阶段全量解码大 input。
  146. func ValidateFunctionCallOutputContextBytes(body []byte) FunctionCallOutputValidation {
  147. result := FunctionCallOutputValidation{}
  148. if len(body) == 0 {
  149. return result
  150. }
  151. // handler 热路径只读扫描 input,避免 GetBytes 为大 Responses body 复制整段 JSON。
  152. input := parseRawJSONView(body).Get("input")
  153. if !input.IsArray() {
  154. return result
  155. }
  156. var callIDs map[string]struct{}
  157. var referenceIDs map[string]struct{}
  158. input.ForEach(func(_, item gjson.Result) bool {
  159. if !item.IsObject() {
  160. return true
  161. }
  162. itemType := item.Get("type").String()
  163. switch {
  164. case isCodexToolCallOutputItemType(itemType):
  165. result.HasFunctionCallOutput = true
  166. callID := strings.TrimSpace(item.Get("call_id").String())
  167. if callID == "" {
  168. result.HasFunctionCallOutputMissingCallID = true
  169. return true
  170. }
  171. if callIDs == nil {
  172. callIDs = make(map[string]struct{})
  173. }
  174. callIDs[callID] = struct{}{}
  175. case isCodexToolCallContextItemType(itemType):
  176. if strings.TrimSpace(item.Get("call_id").String()) != "" {
  177. result.HasToolCallContext = true
  178. }
  179. case itemType == "item_reference":
  180. idValue := strings.TrimSpace(item.Get("id").String())
  181. if idValue == "" {
  182. return true
  183. }
  184. if referenceIDs == nil {
  185. referenceIDs = make(map[string]struct{})
  186. }
  187. referenceIDs[idValue] = struct{}{}
  188. }
  189. return !result.HasFunctionCallOutput || !result.HasToolCallContext
  190. })
  191. if !result.HasFunctionCallOutput || result.HasToolCallContext || len(callIDs) == 0 || len(referenceIDs) == 0 {
  192. return result
  193. }
  194. allReferenced := true
  195. for callID := range callIDs {
  196. if _, ok := referenceIDs[callID]; !ok {
  197. allReferenced = false
  198. break
  199. }
  200. }
  201. result.HasItemReferenceForAllCallIDs = allReferenced
  202. return result
  203. }
  204. // ToolCallOutputContextCoverage 描述 input 中工具输出与可重建上下文的覆盖关系,
  205. // 用于判断剥离 previous_response_id 后上游能否仅凭 input 重建工具续链。
  206. type ToolCallOutputContextCoverage struct {
  207. HasFunctionCallOutput bool
  208. // ContextCoversAllCallIDs 表示每个工具输出的 call_id 都能在 input 内找到
  209. // 同 call_id 的工具调用上下文项或同 id 的 item_reference,且不存在缺失 call_id 的输出。
  210. // 任一输出无法由 input 自身重建时为 false,此时剥离 previous_response_id 会导致
  211. // 上游以 "No tool call found for function call output" 拒绝请求。
  212. ContextCoversAllCallIDs bool
  213. }
  214. // AnalyzeToolCallOutputContextCoverageBytes 全量扫描 input,按 call_id 精确匹配工具输出
  215. // 与可重建上下文。不能复用 ValidateFunctionCallOutputContextBytes 的 HasToolCallContext:
  216. // 该标志只代表"存在某一个上下文项",部分覆盖的续链仍会被上游拒绝。
  217. func AnalyzeToolCallOutputContextCoverageBytes(body []byte) ToolCallOutputContextCoverage {
  218. coverage := ToolCallOutputContextCoverage{}
  219. if len(body) == 0 {
  220. return coverage
  221. }
  222. input := parseRawJSONView(body).Get("input")
  223. if !input.IsArray() && !input.IsObject() {
  224. return coverage
  225. }
  226. missingCallID := false
  227. var outputCallIDs map[string]struct{}
  228. var contextIDs map[string]struct{}
  229. analyzeItem := func(item gjson.Result) {
  230. if !item.IsObject() {
  231. return
  232. }
  233. itemType := item.Get("type").String()
  234. switch {
  235. case isCodexToolCallOutputItemType(itemType):
  236. coverage.HasFunctionCallOutput = true
  237. callID := strings.TrimSpace(item.Get("call_id").String())
  238. if callID == "" {
  239. missingCallID = true
  240. return
  241. }
  242. if outputCallIDs == nil {
  243. outputCallIDs = make(map[string]struct{})
  244. }
  245. outputCallIDs[callID] = struct{}{}
  246. case isCodexToolCallContextItemType(itemType):
  247. callID := strings.TrimSpace(item.Get("call_id").String())
  248. if callID == "" {
  249. return
  250. }
  251. if contextIDs == nil {
  252. contextIDs = make(map[string]struct{})
  253. }
  254. contextIDs[callID] = struct{}{}
  255. case itemType == "item_reference":
  256. idValue := strings.TrimSpace(item.Get("id").String())
  257. if idValue == "" {
  258. return
  259. }
  260. if contextIDs == nil {
  261. contextIDs = make(map[string]struct{})
  262. }
  263. contextIDs[idValue] = struct{}{}
  264. }
  265. }
  266. if input.IsArray() {
  267. input.ForEach(func(_, item gjson.Result) bool {
  268. analyzeItem(item)
  269. return true
  270. })
  271. } else {
  272. analyzeItem(input)
  273. }
  274. if !coverage.HasFunctionCallOutput || missingCallID {
  275. return coverage
  276. }
  277. for callID := range outputCallIDs {
  278. if _, ok := contextIDs[callID]; !ok {
  279. return coverage
  280. }
  281. }
  282. coverage.ContextCoversAllCallIDs = true
  283. return coverage
  284. }
  285. // ValidateFunctionCallOutputContext 为 handler 提供低开销校验结果:
  286. // 1) 无工具输出直接返回
  287. // 2) 若已存在工具调用上下文则提前返回
  288. // 3) 仅在无工具上下文时才构建 call_id / item_reference 集合
  289. // 字段名保留 FunctionCallOutput 是为了兼容既有调用点;语义覆盖所有 Codex 工具输出。
  290. func ValidateFunctionCallOutputContext(reqBody map[string]any) FunctionCallOutputValidation {
  291. result := FunctionCallOutputValidation{}
  292. if reqBody == nil {
  293. return result
  294. }
  295. input, ok := reqBody["input"].([]any)
  296. if !ok {
  297. return result
  298. }
  299. for _, item := range input {
  300. itemMap, ok := item.(map[string]any)
  301. if !ok {
  302. continue
  303. }
  304. itemType, _ := itemMap["type"].(string)
  305. switch {
  306. case isCodexToolCallOutputItemType(itemType):
  307. result.HasFunctionCallOutput = true
  308. case isCodexToolCallContextItemType(itemType):
  309. callID, _ := itemMap["call_id"].(string)
  310. if strings.TrimSpace(callID) != "" {
  311. result.HasToolCallContext = true
  312. }
  313. }
  314. if result.HasFunctionCallOutput && result.HasToolCallContext {
  315. return result
  316. }
  317. }
  318. if !result.HasFunctionCallOutput || result.HasToolCallContext {
  319. return result
  320. }
  321. callIDs := make(map[string]struct{})
  322. referenceIDs := make(map[string]struct{})
  323. for _, item := range input {
  324. itemMap, ok := item.(map[string]any)
  325. if !ok {
  326. continue
  327. }
  328. itemType, _ := itemMap["type"].(string)
  329. switch {
  330. case isCodexToolCallOutputItemType(itemType):
  331. callID, _ := itemMap["call_id"].(string)
  332. callID = strings.TrimSpace(callID)
  333. if callID == "" {
  334. result.HasFunctionCallOutputMissingCallID = true
  335. continue
  336. }
  337. callIDs[callID] = struct{}{}
  338. case itemType == "item_reference":
  339. idValue, _ := itemMap["id"].(string)
  340. idValue = strings.TrimSpace(idValue)
  341. if idValue == "" {
  342. continue
  343. }
  344. referenceIDs[idValue] = struct{}{}
  345. }
  346. }
  347. if len(callIDs) == 0 || len(referenceIDs) == 0 {
  348. return result
  349. }
  350. allReferenced := true
  351. for callID := range callIDs {
  352. if _, ok := referenceIDs[callID]; !ok {
  353. allReferenced = false
  354. break
  355. }
  356. }
  357. result.HasItemReferenceForAllCallIDs = allReferenced
  358. return result
  359. }
  360. // HasFunctionCallOutput 判断 input 是否包含任意 Codex 工具输出,用于触发续链校验。
  361. // 名称保留 function_call_output 是为了兼容既有调用点。
  362. func HasFunctionCallOutput(reqBody map[string]any) bool {
  363. return AnalyzeToolContinuationSignals(reqBody).HasFunctionCallOutput
  364. }
  365. // HasToolCallContext 判断 input 是否包含带 call_id 的工具调用上下文,
  366. // 用于判断工具输出是否具备可关联的上下文。
  367. func HasToolCallContext(reqBody map[string]any) bool {
  368. return AnalyzeToolContinuationSignals(reqBody).HasToolCallContext
  369. }
  370. // FunctionCallOutputCallIDs 提取 input 中工具输出的 call_id 集合。
  371. // 仅返回非空 call_id,用于与 item_reference.id 做匹配校验。
  372. func FunctionCallOutputCallIDs(reqBody map[string]any) []string {
  373. return AnalyzeToolContinuationSignals(reqBody).FunctionCallOutputCallIDs
  374. }
  375. // HasFunctionCallOutputMissingCallID 判断是否存在缺少 call_id 的工具输出。
  376. func HasFunctionCallOutputMissingCallID(reqBody map[string]any) bool {
  377. return AnalyzeToolContinuationSignals(reqBody).HasFunctionCallOutputMissingCallID
  378. }
  379. // HasItemReferenceForCallIDs 判断 item_reference.id 是否覆盖所有 call_id。
  380. // 用于仅依赖引用项完成续链场景的校验。
  381. func HasItemReferenceForCallIDs(reqBody map[string]any, callIDs []string) bool {
  382. if reqBody == nil || len(callIDs) == 0 {
  383. return false
  384. }
  385. input, ok := reqBody["input"].([]any)
  386. if !ok {
  387. return false
  388. }
  389. referenceIDs := make(map[string]struct{})
  390. for _, item := range input {
  391. itemMap, ok := item.(map[string]any)
  392. if !ok {
  393. continue
  394. }
  395. itemType, _ := itemMap["type"].(string)
  396. if itemType != "item_reference" {
  397. continue
  398. }
  399. idValue, _ := itemMap["id"].(string)
  400. idValue = strings.TrimSpace(idValue)
  401. if idValue == "" {
  402. continue
  403. }
  404. referenceIDs[idValue] = struct{}{}
  405. }
  406. if len(referenceIDs) == 0 {
  407. return false
  408. }
  409. for _, callID := range callIDs {
  410. if _, ok := referenceIDs[strings.TrimSpace(callID)]; !ok {
  411. return false
  412. }
  413. }
  414. return true
  415. }
  416. // hasNonEmptyString 判断字段是否为非空字符串。
  417. func hasNonEmptyString(value any) bool {
  418. stringValue, ok := value.(string)
  419. return ok && strings.TrimSpace(stringValue) != ""
  420. }
  421. // hasToolsSignal 判断 tools 字段是否显式声明(存在且不为空)。
  422. func hasToolsSignal(reqBody map[string]any) bool {
  423. raw, exists := reqBody["tools"]
  424. if !exists || raw == nil {
  425. return false
  426. }
  427. if tools, ok := raw.([]any); ok {
  428. return len(tools) > 0
  429. }
  430. return false
  431. }
  432. // hasToolChoiceSignal 判断 tool_choice 是否显式声明(非空或非 nil)。
  433. func hasToolChoiceSignal(reqBody map[string]any) bool {
  434. raw, exists := reqBody["tool_choice"]
  435. if !exists || raw == nil {
  436. return false
  437. }
  438. switch value := raw.(type) {
  439. case string:
  440. return strings.TrimSpace(value) != ""
  441. case map[string]any:
  442. return len(value) > 0
  443. default:
  444. return false
  445. }
  446. }