mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 12:57:57 +08:00
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:
@@ -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