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:
co-authored by
Claude Opus 5
parent
03e8ab4134
commit
e440ac48c7
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user