diff --git a/backend/internal/service/openai_responses_rejected_field_retry.go b/backend/internal/service/openai_responses_rejected_field_retry.go index 20affff9c..f70bdd89a 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry.go +++ b/backend/internal/service/openai_responses_rejected_field_retry.go @@ -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 } diff --git a/backend/internal/service/openai_responses_rejected_field_retry_test.go b/backend/internal/service/openai_responses_rejected_field_retry_test.go index 96b118050..9f794b1e5 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry_test.go +++ b/backend/internal/service/openai_responses_rejected_field_retry_test.go @@ -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()) +}