Merge pull request #6143 from akihitohyh/fix/rejected-status-strip-all

fix(openai): clear the rejected input status for the whole item type
This commit is contained in:
Wesley Liddick
2026-08-24 22:01:14 +08:00
committed by GitHub
2 changed files with 84 additions and 6 deletions
@@ -271,18 +271,56 @@ func openAIResponsesRejectedInputIndex(pattern *regexp.Regexp, param string) (in
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)
if !gjson.GetBytes(body, itemPath).IsObject() {
rejected := gjson.GetBytes(body, itemPath)
if !rejected.IsObject() {
return nil, "", false, nil
}
statusPath := itemPath + ".status"
if !gjson.GetBytes(body, statusPath).Exists() {
if !gjson.GetBytes(body, itemPath+".status").Exists() {
return nil, "", false, nil
}
retryBody, err := sjson.DeleteBytes(body, statusPath)
if err != nil {
return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err)
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
}
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
@@ -681,3 +682,42 @@ func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response
Body: io.NopCloser(strings.NewReader(body)),
}
}
// A replayed conversation carries many items of the same type, each with a
// status the upstream schema rejects. One rejection must clear all of them:
// clearing one index per round trip exhausts the bounded retry budget.
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsStatusForWholeType(t *testing.T) {
input := make([]string, 0, 12)
for i := 0; i < 10; i++ {
input = append(input, `{"type":"tool_search_output","status":"completed","call_id":"call_`+strconv.Itoa(i)+`","tools":[]}`)
}
input = append(input, `{"type":"message","role":"user","status":"completed","content":"hi"}`)
body := []byte(`{"input":[` + strings.Join(input, ",") + `]}`)
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[7].status'.","param":"input[7].status"}}`)
retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
require.NoError(t, err)
require.True(t, changed)
require.NotEmpty(t, reason)
for i := 0; i < 10; i++ {
require.False(t, gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".status").Exists(),
"every tool_search_output must lose its status in a single retry, index %d did not", i)
require.Equal(t, "call_"+strconv.Itoa(i), gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".call_id").String(),
"unrelated fields must survive")
}
require.Equal(t, "completed", gjson.GetBytes(retryBody, "input.10.status").String(),
"a different item type keeps its status: the rejection only proves this type has none")
}
// The rejected item may carry no type to match on.
func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsUntypedStatusAtIndexOnly(t *testing.T) {
body := []byte(`{"input":[{"status":"keep_a"},{"status":"remove"}]}`)
responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`)
retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "keep_a", gjson.GetBytes(retryBody, "input.0.status").String())
require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists())
}