openai_responses_rejected_field_retry.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. package service
  2. import (
  3. "crypto/sha256"
  4. "fmt"
  5. "net/http"
  6. "regexp"
  7. "strconv"
  8. "strings"
  9. "sync"
  10. "github.com/gin-gonic/gin"
  11. "github.com/tidwall/gjson"
  12. "github.com/tidwall/sjson"
  13. )
  14. const maxOpenAIResponsesRejectedFieldRetries = 6
  15. var (
  16. openAIResponsesRejectedNamespaceParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.namespace$`)
  17. openAIResponsesRejectedStatusParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.status$`)
  18. openAIResponsesRejectedContentParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.content$`)
  19. openAIResponsesRejectedCacheParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.prompt_cache_breakpoint$`)
  20. openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|truncation|input\[\d+\]\.(?:namespace|status))(?:["']|\b)`)
  21. openAIResponsesInvalidTypeMessageParamPattern = regexp.MustCompile(`(?i)invalid[ _-]+type\s+for\s+["']?(input\[\d+\]\.content)(?:["']|\b)[^\n]*\b(?:got|received)\s+null\b`)
  22. openAIResponsesMaxZeroContentMessagePattern = regexp.MustCompile(`(?i)invalid\s+["']?(input\[\d+\]\.content)["']?\s*:\s*array too long\.[^\n]*maximum length 0\b`)
  23. openAIResponsesCacheModelRejectionPattern = regexp.MustCompile(`(?i)["']?(prompt_cache_breakpoint|input\[\d+\]\.prompt_cache_breakpoint)["']?\s+is\s+not\s+supported\s+on\s+this\s+model\b`)
  24. openAIResponsesToolParametersParamPattern = regexp.MustCompile(`(?i)^(?:tools|input)\[\d+\](?:\.tools\[\d+\])*(?:\.function)?\.parameters$`)
  25. openAIResponsesMissingSchemaTypePattern = regexp.MustCompile(`(?i)\bgot\s+["']?type\s*:\s*["']?none["']?`)
  26. )
  27. type openAIResponsesRejectedFieldRetryState struct {
  28. mu sync.Mutex
  29. budget *openAIResponsesRejectedFieldRetryBudget
  30. seenBodyHashes map[[sha256.Size]byte]struct{}
  31. }
  32. type openAIResponsesRejectedFieldRetryBudget struct {
  33. mu sync.Mutex
  34. attempts int
  35. }
  36. const openAIResponsesRejectedFieldRetryBudgetContextKey = "openai_responses_rejected_field_retry_budget"
  37. // openAIResponsesRejectedFieldRetryStateForRequest returns a fresh loop guard
  38. // for one account attempt backed by the inbound request's shared retry budget.
  39. // A later account may apply the same compatibility transform, while all account
  40. // attempts together remain bounded.
  41. func openAIResponsesRejectedFieldRetryStateForRequest(c *gin.Context, initialBody []byte) *openAIResponsesRejectedFieldRetryState {
  42. var budget *openAIResponsesRejectedFieldRetryBudget
  43. if c != nil {
  44. if existing, ok := c.Get(openAIResponsesRejectedFieldRetryBudgetContextKey); ok {
  45. budget, _ = existing.(*openAIResponsesRejectedFieldRetryBudget)
  46. }
  47. }
  48. if budget == nil {
  49. budget = &openAIResponsesRejectedFieldRetryBudget{}
  50. if c != nil {
  51. c.Set(openAIResponsesRejectedFieldRetryBudgetContextKey, budget)
  52. }
  53. }
  54. return newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody, budget)
  55. }
  56. func newOpenAIResponsesRejectedFieldRetryState(initialBody []byte) *openAIResponsesRejectedFieldRetryState {
  57. return newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody, &openAIResponsesRejectedFieldRetryBudget{})
  58. }
  59. func newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody []byte, budget *openAIResponsesRejectedFieldRetryBudget) *openAIResponsesRejectedFieldRetryState {
  60. state := &openAIResponsesRejectedFieldRetryState{
  61. budget: budget,
  62. seenBodyHashes: make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1),
  63. }
  64. state.remember(initialBody)
  65. return state
  66. }
  67. func (s *openAIResponsesRejectedFieldRetryState) Allow(nextBody []byte) bool {
  68. if s == nil || s.budget == nil || len(nextBody) == 0 {
  69. return false
  70. }
  71. s.mu.Lock()
  72. defer s.mu.Unlock()
  73. bodyHash := sha256.Sum256(nextBody)
  74. if _, seen := s.seenBodyHashes[bodyHash]; seen {
  75. return false
  76. }
  77. s.budget.mu.Lock()
  78. defer s.budget.mu.Unlock()
  79. if s.budget.attempts >= maxOpenAIResponsesRejectedFieldRetries {
  80. return false
  81. }
  82. s.seenBodyHashes[bodyHash] = struct{}{}
  83. s.budget.attempts++
  84. return true
  85. }
  86. func (s *openAIResponsesRejectedFieldRetryState) remember(body []byte) {
  87. if s == nil || len(body) == 0 {
  88. return
  89. }
  90. s.mu.Lock()
  91. defer s.mu.Unlock()
  92. s.rememberLocked(body)
  93. }
  94. func (s *openAIResponsesRejectedFieldRetryState) rememberLocked(body []byte) {
  95. if s.seenBodyHashes == nil {
  96. s.seenBodyHashes = make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1)
  97. }
  98. s.seenBodyHashes[sha256.Sum256(body)] = struct{}{}
  99. }
  100. func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, responseBody []byte) ([]byte, string, bool, error) {
  101. if statusCode != http.StatusBadRequest || len(body) == 0 || len(responseBody) == 0 {
  102. return nil, "", false, nil
  103. }
  104. code := strings.ToLower(strings.TrimSpace(extractUpstreamErrorCode(responseBody)))
  105. message := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(responseBody)))
  106. param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String()))
  107. if code == "invalid_function_parameters" &&
  108. openAIResponsesToolParametersParamPattern.MatchString(param) &&
  109. openAIResponsesMissingSchemaTypePattern.MatchString(message) {
  110. retryBody, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
  111. if err != nil {
  112. return nil, "", false, fmt.Errorf("repair rejected tool parameter root type: %w", err)
  113. }
  114. if changed {
  115. return retryBody, "tool parameter root type rejection", true, nil
  116. }
  117. }
  118. cacheMessageParam := openAIResponsesCacheModelRejectionParamFromMessage(message)
  119. cacheParam := param
  120. if cacheParam == "" {
  121. cacheParam = cacheMessageParam
  122. }
  123. cacheParamMatchesMessage := cacheMessageParam == "" || cacheParam == cacheMessageParam
  124. cacheModelRejection := code == "invalid_parameter" || cacheMessageParam != ""
  125. if cacheParam != "" && cacheParamMatchesMessage && cacheModelRejection {
  126. if cacheParam == "prompt_cache_breakpoint" && gjson.GetBytes(body, cacheParam).Exists() {
  127. retryBody, err := sjson.DeleteBytes(body, cacheParam)
  128. if err != nil {
  129. return nil, "", false, fmt.Errorf("delete rejected prompt_cache_breakpoint: %w", err)
  130. }
  131. return retryBody, "prompt_cache_breakpoint parameter rejection", true, nil
  132. }
  133. if index, ok := openAIResponsesRejectedCacheIndex(cacheParam); ok {
  134. return removeOpenAIResponsesRejectedCacheAtIndex(body, index)
  135. }
  136. }
  137. if isExplicitOpenAIResponsesFieldRejection(code, message) {
  138. messageParam := openAIResponsesRejectedParamFromMessage(message)
  139. if param != "" && messageParam != "" && param != messageParam {
  140. return nil, "", false, nil
  141. }
  142. if param == "" {
  143. param = messageParam
  144. }
  145. if index, ok := openAIResponsesRejectedNamespaceIndex(param); ok {
  146. return removeOpenAIResponsesRejectedNamespaceAtIndex(body, index)
  147. }
  148. if index, ok := openAIResponsesRejectedStatusIndex(param); ok {
  149. return removeOpenAIResponsesRejectedStatusAtIndex(body, index)
  150. }
  151. if param == "max_output_tokens" && gjson.GetBytes(body, "max_output_tokens").Exists() {
  152. retryBody, err := sjson.DeleteBytes(body, "max_output_tokens")
  153. if err != nil {
  154. return nil, "", false, fmt.Errorf("delete rejected max_output_tokens: %w", err)
  155. }
  156. return retryBody, "max_output_tokens parameter rejection", true, nil
  157. }
  158. if param == "truncation" && gjson.GetBytes(body, "truncation").Exists() {
  159. retryBody, err := sjson.DeleteBytes(body, "truncation")
  160. if err != nil {
  161. return nil, "", false, fmt.Errorf("delete rejected truncation: %w", err)
  162. }
  163. return retryBody, "truncation parameter rejection", true, nil
  164. }
  165. }
  166. messageContentParam := openAIResponsesInvalidTypeParamFromMessage(message)
  167. contentParam := param
  168. if contentParam == "" {
  169. contentParam = messageContentParam
  170. }
  171. if index, ok := openAIResponsesRejectedContentIndex(contentParam); ok &&
  172. contentParam == messageContentParam && isExplicitOpenAIResponsesNullContentRejection(code, message) {
  173. return normalizeOpenAIResponsesRejectedNullContentAtIndex(body, index)
  174. }
  175. maxZeroContentParam := openAIResponsesMaxZeroContentParamFromMessage(message)
  176. if index, ok := openAIResponsesRejectedContentIndex(param); ok &&
  177. param == maxZeroContentParam && code == "array_above_max_length" {
  178. return removeOpenAIResponsesRejectedReasoningContentAtIndex(body, index)
  179. }
  180. return nil, "", false, nil
  181. }
  182. func isExplicitOpenAIResponsesFieldRejection(code, message string) bool {
  183. switch strings.TrimSpace(code) {
  184. case "unknown_parameter", "unsupported_parameter":
  185. return true
  186. }
  187. return strings.Contains(message, "unknown parameter") ||
  188. strings.Contains(message, "unsupported parameter")
  189. }
  190. func openAIResponsesRejectedParamFromMessage(message string) string {
  191. match := openAIResponsesRejectedMessageParamPattern.FindStringSubmatch(strings.TrimSpace(message))
  192. if len(match) != 2 {
  193. return ""
  194. }
  195. return strings.ToLower(strings.TrimSpace(match[1]))
  196. }
  197. func openAIResponsesMaxZeroContentParamFromMessage(message string) string {
  198. match := openAIResponsesMaxZeroContentMessagePattern.FindStringSubmatch(strings.TrimSpace(message))
  199. if len(match) != 2 {
  200. return ""
  201. }
  202. return strings.ToLower(strings.TrimSpace(match[1]))
  203. }
  204. func openAIResponsesInvalidTypeParamFromMessage(message string) string {
  205. match := openAIResponsesInvalidTypeMessageParamPattern.FindStringSubmatch(strings.TrimSpace(message))
  206. if len(match) != 2 {
  207. return ""
  208. }
  209. return strings.ToLower(strings.TrimSpace(match[1]))
  210. }
  211. func openAIResponsesCacheModelRejectionParamFromMessage(message string) string {
  212. match := openAIResponsesCacheModelRejectionPattern.FindStringSubmatch(strings.TrimSpace(message))
  213. if len(match) != 2 {
  214. return ""
  215. }
  216. return strings.ToLower(strings.TrimSpace(match[1]))
  217. }
  218. func isExplicitOpenAIResponsesNullContentRejection(code, message string) bool {
  219. code = strings.TrimSpace(code)
  220. return (code == "invalid_type" || code == "invalid_request_error" || code == "") &&
  221. openAIResponsesInvalidTypeMessageParamPattern.MatchString(strings.TrimSpace(message))
  222. }
  223. func openAIResponsesRejectedNamespaceIndex(param string) (int, bool) {
  224. return openAIResponsesRejectedInputIndex(openAIResponsesRejectedNamespaceParamPattern, param)
  225. }
  226. func openAIResponsesRejectedStatusIndex(param string) (int, bool) {
  227. return openAIResponsesRejectedInputIndex(openAIResponsesRejectedStatusParamPattern, param)
  228. }
  229. func openAIResponsesRejectedContentIndex(param string) (int, bool) {
  230. return openAIResponsesRejectedInputIndex(openAIResponsesRejectedContentParamPattern, param)
  231. }
  232. func openAIResponsesRejectedCacheIndex(param string) (int, bool) {
  233. return openAIResponsesRejectedInputIndex(openAIResponsesRejectedCacheParamPattern, param)
  234. }
  235. func openAIResponsesRejectedInputIndex(pattern *regexp.Regexp, param string) (int, bool) {
  236. match := pattern.FindStringSubmatch(strings.TrimSpace(param))
  237. if len(match) != 2 {
  238. return 0, false
  239. }
  240. index, err := strconv.Atoi(match[1])
  241. if err == nil && index >= 0 {
  242. return index, true
  243. }
  244. return 0, false
  245. }
  246. // removeOpenAIResponsesRejectedStatusAtIndex drops the status field the
  247. // upstream rejected, and the status of every other input item sharing the
  248. // rejected item's type.
  249. //
  250. // The upstream names one offending index per response, but a replayed
  251. // conversation routinely carries dozens of items of the same type, each with a
  252. // status its schema does not accept. Clearing one index per round trip would
  253. // need one retry per item and exhaust the bounded retry budget long before the
  254. // request could succeed. Items of other types keep their status: the rejection
  255. // only proves that this type has no status field.
  256. func removeOpenAIResponsesRejectedStatusAtIndex(body []byte, index int) ([]byte, string, bool, error) {
  257. itemPath := fmt.Sprintf("input.%d", index)
  258. rejected := gjson.GetBytes(body, itemPath)
  259. if !rejected.IsObject() {
  260. return nil, "", false, nil
  261. }
  262. if !gjson.GetBytes(body, itemPath+".status").Exists() {
  263. return nil, "", false, nil
  264. }
  265. retryBody := body
  266. cleared := 0
  267. rejectedType := strings.TrimSpace(rejected.Get("type").String())
  268. if input := gjson.GetBytes(body, "input"); rejectedType != "" && input.IsArray() {
  269. // Deleting a field never shifts array indexes, so positions read from
  270. // the original body stay valid against the rewritten one.
  271. for itemIndex, item := range input.Array() {
  272. if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != rejectedType {
  273. continue
  274. }
  275. statusPath := fmt.Sprintf("input.%d.status", itemIndex)
  276. if !gjson.GetBytes(retryBody, statusPath).Exists() {
  277. continue
  278. }
  279. next, err := sjson.DeleteBytes(retryBody, statusPath)
  280. if err != nil {
  281. return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", itemIndex, err)
  282. }
  283. retryBody = next
  284. cleared++
  285. }
  286. }
  287. if cleared == 0 {
  288. // The rejected item carries no type to match on; fall back to clearing
  289. // just the index the upstream named.
  290. next, err := sjson.DeleteBytes(retryBody, itemPath+".status")
  291. if err != nil {
  292. return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err)
  293. }
  294. retryBody = next
  295. }
  296. return retryBody, "indexed status parameter rejection", true, nil
  297. }
  298. func removeOpenAIResponsesRejectedCacheAtIndex(body []byte, index int) ([]byte, string, bool, error) {
  299. itemPath := fmt.Sprintf("input.%d", index)
  300. if !gjson.GetBytes(body, itemPath).IsObject() {
  301. return nil, "", false, nil
  302. }
  303. cachePath := itemPath + ".prompt_cache_breakpoint"
  304. if !gjson.GetBytes(body, cachePath).Exists() {
  305. return nil, "", false, nil
  306. }
  307. retryBody, err := sjson.DeleteBytes(body, cachePath)
  308. if err != nil {
  309. return nil, "", false, fmt.Errorf("delete rejected prompt_cache_breakpoint at input[%d]: %w", index, err)
  310. }
  311. return retryBody, "indexed prompt_cache_breakpoint parameter rejection", true, nil
  312. }
  313. func normalizeOpenAIResponsesRejectedNullContentAtIndex(body []byte, index int) ([]byte, string, bool, error) {
  314. itemPath := fmt.Sprintf("input.%d", index)
  315. item := gjson.GetBytes(body, itemPath)
  316. content := gjson.GetBytes(body, itemPath+".content")
  317. if !item.IsObject() || !content.Exists() || content.Type != gjson.Null {
  318. return nil, "", false, nil
  319. }
  320. itemType := strings.ToLower(strings.TrimSpace(item.Get("type").String()))
  321. role := strings.TrimSpace(item.Get("role").String())
  322. contentPath := itemPath + ".content"
  323. switch {
  324. case itemType == "reasoning":
  325. retryBody, err := sjson.DeleteBytes(body, contentPath)
  326. if err != nil {
  327. return nil, "", false, fmt.Errorf("delete rejected null content at input[%d]: %w", index, err)
  328. }
  329. return retryBody, "indexed reasoning null content rejection", true, nil
  330. case itemType == "message" || role != "":
  331. retryBody, err := sjson.SetBytes(body, contentPath, "")
  332. if err != nil {
  333. return nil, "", false, fmt.Errorf("normalize rejected null content at input[%d]: %w", index, err)
  334. }
  335. return retryBody, "indexed message null content rejection", true, nil
  336. default:
  337. return nil, "", false, nil
  338. }
  339. }
  340. func removeOpenAIResponsesRejectedReasoningContentAtIndex(body []byte, index int) ([]byte, string, bool, error) {
  341. itemPath := fmt.Sprintf("input.%d", index)
  342. item := gjson.GetBytes(body, itemPath)
  343. content := item.Get("content")
  344. if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != "reasoning" || !content.IsArray() || len(content.Array()) == 0 {
  345. return nil, "", false, nil
  346. }
  347. retryBody, err := sjson.DeleteBytes(body, itemPath+".content")
  348. if err != nil {
  349. return nil, "", false, fmt.Errorf("delete rejected reasoning content at input[%d]: %w", index, err)
  350. }
  351. return retryBody, "indexed reasoning content maximum-length rejection", true, nil
  352. }
  353. func removeOpenAIResponsesRejectedNamespaceAtIndex(body []byte, index int) ([]byte, string, bool, error) {
  354. itemPath := fmt.Sprintf("input.%d", index)
  355. itemType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, itemPath+".type").String()))
  356. switch itemType {
  357. case "function_call", "tool_call", "custom_tool_call", "mcp_tool_call":
  358. default:
  359. return nil, "", false, nil
  360. }
  361. namespacePath := itemPath + ".namespace"
  362. if !gjson.GetBytes(body, namespacePath).Exists() {
  363. return nil, "", false, nil
  364. }
  365. retryBody, err := sjson.DeleteBytes(body, namespacePath)
  366. if err != nil {
  367. return nil, "", false, fmt.Errorf("delete rejected namespace at input[%d]: %w", index, err)
  368. }
  369. return retryBody, "indexed namespace parameter rejection", true, nil
  370. }