mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
The upstream names one offending index per response, but a replayed conversation routinely carries dozens of items of the same type, each with a status the upstream schema does not accept. Clearing a single index per round trip needs one retry per item, so a conversation with more than maxOpenAIResponsesRejectedFieldRetries such items exhausts the bounded budget and the 400 reaches the client. Reported against tool_search_output items, where the rejection surfaced as "Unknown parameter: 'input[60].status'". Clear the status of every input item sharing the rejected item's type in the same pass. Items of other types keep theirs: the rejection only proves that the rejected item's type has no status field. When the rejected item carries no type to match on, fall back to clearing the named index alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
406 lines
16 KiB
Go
406 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
const maxOpenAIResponsesRejectedFieldRetries = 6
|
|
|
|
var (
|
|
openAIResponsesRejectedNamespaceParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.namespace$`)
|
|
openAIResponsesRejectedStatusParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.status$`)
|
|
openAIResponsesRejectedContentParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.content$`)
|
|
openAIResponsesRejectedCacheParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.prompt_cache_breakpoint$`)
|
|
openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|truncation|input\[\d+\]\.(?:namespace|status))(?:["']|\b)`)
|
|
openAIResponsesInvalidTypeMessageParamPattern = regexp.MustCompile(`(?i)invalid[ _-]+type\s+for\s+["']?(input\[\d+\]\.content)(?:["']|\b)[^\n]*\b(?:got|received)\s+null\b`)
|
|
openAIResponsesMaxZeroContentMessagePattern = regexp.MustCompile(`(?i)invalid\s+["']?(input\[\d+\]\.content)["']?\s*:\s*array too long\.[^\n]*maximum length 0\b`)
|
|
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`)
|
|
openAIResponsesToolParametersParamPattern = regexp.MustCompile(`(?i)^(?:tools|input)\[\d+\](?:\.tools\[\d+\])*(?:\.function)?\.parameters$`)
|
|
openAIResponsesMissingSchemaTypePattern = regexp.MustCompile(`(?i)\bgot\s+["']?type\s*:\s*["']?none["']?`)
|
|
)
|
|
|
|
type openAIResponsesRejectedFieldRetryState struct {
|
|
mu sync.Mutex
|
|
budget *openAIResponsesRejectedFieldRetryBudget
|
|
seenBodyHashes map[[sha256.Size]byte]struct{}
|
|
}
|
|
|
|
type openAIResponsesRejectedFieldRetryBudget struct {
|
|
mu sync.Mutex
|
|
attempts int
|
|
}
|
|
|
|
const openAIResponsesRejectedFieldRetryBudgetContextKey = "openai_responses_rejected_field_retry_budget"
|
|
|
|
// openAIResponsesRejectedFieldRetryStateForRequest returns a fresh loop guard
|
|
// for one account attempt backed by the inbound request's shared retry budget.
|
|
// A later account may apply the same compatibility transform, while all account
|
|
// attempts together remain bounded.
|
|
func openAIResponsesRejectedFieldRetryStateForRequest(c *gin.Context, initialBody []byte) *openAIResponsesRejectedFieldRetryState {
|
|
var budget *openAIResponsesRejectedFieldRetryBudget
|
|
if c != nil {
|
|
if existing, ok := c.Get(openAIResponsesRejectedFieldRetryBudgetContextKey); ok {
|
|
budget, _ = existing.(*openAIResponsesRejectedFieldRetryBudget)
|
|
}
|
|
}
|
|
if budget == nil {
|
|
budget = &openAIResponsesRejectedFieldRetryBudget{}
|
|
if c != nil {
|
|
c.Set(openAIResponsesRejectedFieldRetryBudgetContextKey, budget)
|
|
}
|
|
}
|
|
return newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody, budget)
|
|
}
|
|
|
|
func newOpenAIResponsesRejectedFieldRetryState(initialBody []byte) *openAIResponsesRejectedFieldRetryState {
|
|
return newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody, &openAIResponsesRejectedFieldRetryBudget{})
|
|
}
|
|
|
|
func newOpenAIResponsesRejectedFieldRetryStateWithBudget(initialBody []byte, budget *openAIResponsesRejectedFieldRetryBudget) *openAIResponsesRejectedFieldRetryState {
|
|
state := &openAIResponsesRejectedFieldRetryState{
|
|
budget: budget,
|
|
seenBodyHashes: make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1),
|
|
}
|
|
state.remember(initialBody)
|
|
return state
|
|
}
|
|
|
|
func (s *openAIResponsesRejectedFieldRetryState) Allow(nextBody []byte) bool {
|
|
if s == nil || s.budget == nil || len(nextBody) == 0 {
|
|
return false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
bodyHash := sha256.Sum256(nextBody)
|
|
if _, seen := s.seenBodyHashes[bodyHash]; seen {
|
|
return false
|
|
}
|
|
s.budget.mu.Lock()
|
|
defer s.budget.mu.Unlock()
|
|
if s.budget.attempts >= maxOpenAIResponsesRejectedFieldRetries {
|
|
return false
|
|
}
|
|
s.seenBodyHashes[bodyHash] = struct{}{}
|
|
s.budget.attempts++
|
|
return true
|
|
}
|
|
|
|
func (s *openAIResponsesRejectedFieldRetryState) remember(body []byte) {
|
|
if s == nil || len(body) == 0 {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.rememberLocked(body)
|
|
}
|
|
|
|
func (s *openAIResponsesRejectedFieldRetryState) rememberLocked(body []byte) {
|
|
if s.seenBodyHashes == nil {
|
|
s.seenBodyHashes = make(map[[sha256.Size]byte]struct{}, maxOpenAIResponsesRejectedFieldRetries+1)
|
|
}
|
|
s.seenBodyHashes[sha256.Sum256(body)] = struct{}{}
|
|
}
|
|
|
|
func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, responseBody []byte) ([]byte, string, bool, error) {
|
|
if statusCode != http.StatusBadRequest || len(body) == 0 || len(responseBody) == 0 {
|
|
return nil, "", false, nil
|
|
}
|
|
|
|
code := strings.ToLower(strings.TrimSpace(extractUpstreamErrorCode(responseBody)))
|
|
message := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(responseBody)))
|
|
param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String()))
|
|
if code == "invalid_function_parameters" &&
|
|
openAIResponsesToolParametersParamPattern.MatchString(param) &&
|
|
openAIResponsesMissingSchemaTypePattern.MatchString(message) {
|
|
retryBody, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body)
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("repair rejected tool parameter root type: %w", err)
|
|
}
|
|
if changed {
|
|
return retryBody, "tool parameter root type rejection", true, nil
|
|
}
|
|
}
|
|
cacheMessageParam := openAIResponsesCacheModelRejectionParamFromMessage(message)
|
|
cacheParam := param
|
|
if cacheParam == "" {
|
|
cacheParam = cacheMessageParam
|
|
}
|
|
cacheParamMatchesMessage := cacheMessageParam == "" || cacheParam == cacheMessageParam
|
|
cacheModelRejection := code == "invalid_parameter" || cacheMessageParam != ""
|
|
if cacheParam != "" && cacheParamMatchesMessage && cacheModelRejection {
|
|
if cacheParam == "prompt_cache_breakpoint" && gjson.GetBytes(body, cacheParam).Exists() {
|
|
retryBody, err := sjson.DeleteBytes(body, cacheParam)
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("delete rejected prompt_cache_breakpoint: %w", err)
|
|
}
|
|
return retryBody, "prompt_cache_breakpoint parameter rejection", true, nil
|
|
}
|
|
if index, ok := openAIResponsesRejectedCacheIndex(cacheParam); ok {
|
|
return removeOpenAIResponsesRejectedCacheAtIndex(body, index)
|
|
}
|
|
}
|
|
if isExplicitOpenAIResponsesFieldRejection(code, message) {
|
|
messageParam := openAIResponsesRejectedParamFromMessage(message)
|
|
if param != "" && messageParam != "" && param != messageParam {
|
|
return nil, "", false, nil
|
|
}
|
|
if param == "" {
|
|
param = messageParam
|
|
}
|
|
if index, ok := openAIResponsesRejectedNamespaceIndex(param); ok {
|
|
return removeOpenAIResponsesRejectedNamespaceAtIndex(body, index)
|
|
}
|
|
if index, ok := openAIResponsesRejectedStatusIndex(param); ok {
|
|
return removeOpenAIResponsesRejectedStatusAtIndex(body, index)
|
|
}
|
|
if param == "max_output_tokens" && gjson.GetBytes(body, "max_output_tokens").Exists() {
|
|
retryBody, err := sjson.DeleteBytes(body, "max_output_tokens")
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("delete rejected max_output_tokens: %w", err)
|
|
}
|
|
return retryBody, "max_output_tokens parameter rejection", true, nil
|
|
}
|
|
if param == "truncation" && gjson.GetBytes(body, "truncation").Exists() {
|
|
retryBody, err := sjson.DeleteBytes(body, "truncation")
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("delete rejected truncation: %w", err)
|
|
}
|
|
return retryBody, "truncation parameter rejection", true, nil
|
|
}
|
|
}
|
|
|
|
messageContentParam := openAIResponsesInvalidTypeParamFromMessage(message)
|
|
contentParam := param
|
|
if contentParam == "" {
|
|
contentParam = messageContentParam
|
|
}
|
|
if index, ok := openAIResponsesRejectedContentIndex(contentParam); ok &&
|
|
contentParam == messageContentParam && isExplicitOpenAIResponsesNullContentRejection(code, message) {
|
|
return normalizeOpenAIResponsesRejectedNullContentAtIndex(body, index)
|
|
}
|
|
maxZeroContentParam := openAIResponsesMaxZeroContentParamFromMessage(message)
|
|
if index, ok := openAIResponsesRejectedContentIndex(param); ok &&
|
|
param == maxZeroContentParam && code == "array_above_max_length" {
|
|
return removeOpenAIResponsesRejectedReasoningContentAtIndex(body, index)
|
|
}
|
|
return nil, "", false, nil
|
|
}
|
|
|
|
func isExplicitOpenAIResponsesFieldRejection(code, message string) bool {
|
|
switch strings.TrimSpace(code) {
|
|
case "unknown_parameter", "unsupported_parameter":
|
|
return true
|
|
}
|
|
return strings.Contains(message, "unknown parameter") ||
|
|
strings.Contains(message, "unsupported parameter")
|
|
}
|
|
|
|
func openAIResponsesRejectedParamFromMessage(message string) string {
|
|
match := openAIResponsesRejectedMessageParamPattern.FindStringSubmatch(strings.TrimSpace(message))
|
|
if len(match) != 2 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(match[1]))
|
|
}
|
|
|
|
func openAIResponsesMaxZeroContentParamFromMessage(message string) string {
|
|
match := openAIResponsesMaxZeroContentMessagePattern.FindStringSubmatch(strings.TrimSpace(message))
|
|
if len(match) != 2 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(match[1]))
|
|
}
|
|
|
|
func openAIResponsesInvalidTypeParamFromMessage(message string) string {
|
|
match := openAIResponsesInvalidTypeMessageParamPattern.FindStringSubmatch(strings.TrimSpace(message))
|
|
if len(match) != 2 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(match[1]))
|
|
}
|
|
|
|
func openAIResponsesCacheModelRejectionParamFromMessage(message string) string {
|
|
match := openAIResponsesCacheModelRejectionPattern.FindStringSubmatch(strings.TrimSpace(message))
|
|
if len(match) != 2 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(match[1]))
|
|
}
|
|
|
|
func isExplicitOpenAIResponsesNullContentRejection(code, message string) bool {
|
|
code = strings.TrimSpace(code)
|
|
return (code == "invalid_type" || code == "invalid_request_error" || code == "") &&
|
|
openAIResponsesInvalidTypeMessageParamPattern.MatchString(strings.TrimSpace(message))
|
|
}
|
|
|
|
func openAIResponsesRejectedNamespaceIndex(param string) (int, bool) {
|
|
return openAIResponsesRejectedInputIndex(openAIResponsesRejectedNamespaceParamPattern, param)
|
|
}
|
|
|
|
func openAIResponsesRejectedStatusIndex(param string) (int, bool) {
|
|
return openAIResponsesRejectedInputIndex(openAIResponsesRejectedStatusParamPattern, param)
|
|
}
|
|
|
|
func openAIResponsesRejectedContentIndex(param string) (int, bool) {
|
|
return openAIResponsesRejectedInputIndex(openAIResponsesRejectedContentParamPattern, param)
|
|
}
|
|
|
|
func openAIResponsesRejectedCacheIndex(param string) (int, bool) {
|
|
return openAIResponsesRejectedInputIndex(openAIResponsesRejectedCacheParamPattern, param)
|
|
}
|
|
|
|
func openAIResponsesRejectedInputIndex(pattern *regexp.Regexp, param string) (int, bool) {
|
|
match := pattern.FindStringSubmatch(strings.TrimSpace(param))
|
|
if len(match) != 2 {
|
|
return 0, false
|
|
}
|
|
index, err := strconv.Atoi(match[1])
|
|
if err == nil && index >= 0 {
|
|
return index, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// removeOpenAIResponsesRejectedStatusAtIndex drops the status field the
|
|
// upstream rejected, and the status of every other input item sharing the
|
|
// rejected item's type.
|
|
//
|
|
// The upstream names one offending index per response, but a replayed
|
|
// conversation routinely carries dozens of items of the same type, each with a
|
|
// status its schema does not accept. Clearing one index per round trip would
|
|
// need one retry per item and exhaust the bounded retry budget long before the
|
|
// request could succeed. Items of other types keep their status: the rejection
|
|
// only proves that this type has no status field.
|
|
func removeOpenAIResponsesRejectedStatusAtIndex(body []byte, index int) ([]byte, string, bool, error) {
|
|
itemPath := fmt.Sprintf("input.%d", index)
|
|
rejected := gjson.GetBytes(body, itemPath)
|
|
if !rejected.IsObject() {
|
|
return nil, "", false, nil
|
|
}
|
|
if !gjson.GetBytes(body, itemPath+".status").Exists() {
|
|
return nil, "", false, nil
|
|
}
|
|
|
|
retryBody := body
|
|
cleared := 0
|
|
rejectedType := strings.TrimSpace(rejected.Get("type").String())
|
|
if input := gjson.GetBytes(body, "input"); rejectedType != "" && input.IsArray() {
|
|
// Deleting a field never shifts array indexes, so positions read from
|
|
// the original body stay valid against the rewritten one.
|
|
for itemIndex, item := range input.Array() {
|
|
if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != rejectedType {
|
|
continue
|
|
}
|
|
statusPath := fmt.Sprintf("input.%d.status", itemIndex)
|
|
if !gjson.GetBytes(retryBody, statusPath).Exists() {
|
|
continue
|
|
}
|
|
next, err := sjson.DeleteBytes(retryBody, statusPath)
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", itemIndex, err)
|
|
}
|
|
retryBody = next
|
|
cleared++
|
|
}
|
|
}
|
|
if cleared == 0 {
|
|
// The rejected item carries no type to match on; fall back to clearing
|
|
// just the index the upstream named.
|
|
next, err := sjson.DeleteBytes(retryBody, itemPath+".status")
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err)
|
|
}
|
|
retryBody = next
|
|
}
|
|
return retryBody, "indexed status parameter rejection", true, nil
|
|
}
|
|
|
|
func removeOpenAIResponsesRejectedCacheAtIndex(body []byte, index int) ([]byte, string, bool, error) {
|
|
itemPath := fmt.Sprintf("input.%d", index)
|
|
if !gjson.GetBytes(body, itemPath).IsObject() {
|
|
return nil, "", false, nil
|
|
}
|
|
cachePath := itemPath + ".prompt_cache_breakpoint"
|
|
if !gjson.GetBytes(body, cachePath).Exists() {
|
|
return nil, "", false, nil
|
|
}
|
|
retryBody, err := sjson.DeleteBytes(body, cachePath)
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("delete rejected prompt_cache_breakpoint at input[%d]: %w", index, err)
|
|
}
|
|
return retryBody, "indexed prompt_cache_breakpoint parameter rejection", true, nil
|
|
}
|
|
|
|
func normalizeOpenAIResponsesRejectedNullContentAtIndex(body []byte, index int) ([]byte, string, bool, error) {
|
|
itemPath := fmt.Sprintf("input.%d", index)
|
|
item := gjson.GetBytes(body, itemPath)
|
|
content := gjson.GetBytes(body, itemPath+".content")
|
|
if !item.IsObject() || !content.Exists() || content.Type != gjson.Null {
|
|
return nil, "", false, nil
|
|
}
|
|
|
|
itemType := strings.ToLower(strings.TrimSpace(item.Get("type").String()))
|
|
role := strings.TrimSpace(item.Get("role").String())
|
|
contentPath := itemPath + ".content"
|
|
switch {
|
|
case itemType == "reasoning":
|
|
retryBody, err := sjson.DeleteBytes(body, contentPath)
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("delete rejected null content at input[%d]: %w", index, err)
|
|
}
|
|
return retryBody, "indexed reasoning null content rejection", true, nil
|
|
case itemType == "message" || role != "":
|
|
retryBody, err := sjson.SetBytes(body, contentPath, "")
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("normalize rejected null content at input[%d]: %w", index, err)
|
|
}
|
|
return retryBody, "indexed message null content rejection", true, nil
|
|
default:
|
|
return nil, "", false, nil
|
|
}
|
|
}
|
|
|
|
func removeOpenAIResponsesRejectedReasoningContentAtIndex(body []byte, index int) ([]byte, string, bool, error) {
|
|
itemPath := fmt.Sprintf("input.%d", index)
|
|
item := gjson.GetBytes(body, itemPath)
|
|
content := item.Get("content")
|
|
if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != "reasoning" || !content.IsArray() || len(content.Array()) == 0 {
|
|
return nil, "", false, nil
|
|
}
|
|
retryBody, err := sjson.DeleteBytes(body, itemPath+".content")
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("delete rejected reasoning content at input[%d]: %w", index, err)
|
|
}
|
|
return retryBody, "indexed reasoning content maximum-length rejection", true, nil
|
|
}
|
|
|
|
func removeOpenAIResponsesRejectedNamespaceAtIndex(body []byte, index int) ([]byte, string, bool, error) {
|
|
itemPath := fmt.Sprintf("input.%d", index)
|
|
itemType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, itemPath+".type").String()))
|
|
switch itemType {
|
|
case "function_call", "tool_call", "custom_tool_call", "mcp_tool_call":
|
|
default:
|
|
return nil, "", false, nil
|
|
}
|
|
|
|
namespacePath := itemPath + ".namespace"
|
|
if !gjson.GetBytes(body, namespacePath).Exists() {
|
|
return nil, "", false, nil
|
|
}
|
|
retryBody, err := sjson.DeleteBytes(body, namespacePath)
|
|
if err != nil {
|
|
return nil, "", false, fmt.Errorf("delete rejected namespace at input[%d]: %w", index, err)
|
|
}
|
|
return retryBody, "indexed namespace parameter rejection", true, nil
|
|
}
|