fix(openai): clear the rejected input status for the whole item 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 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>
This commit is contained in:
akihitohyh
2026-08-24 17:03:44 +08:00
co-authored by Claude Opus 5
parent 03e8ab4134
commit e440ac48c7
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())
}