Merge pull request #6200 from xuhaihan/fix/openai-reasoning-replay

fix(openai): normalize cross-provider reasoning replay
This commit is contained in:
Wesley Liddick
2026-08-28 11:37:33 +08:00
committed by GitHub
4 changed files with 163 additions and 0 deletions
@@ -182,6 +182,17 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
}
if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) {
normalizedReasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningContentReplay(body)
if reasoningErr != nil {
return nil, fmt.Errorf("normalize OpenAI Responses reasoning content replay: %w", reasoningErr)
}
if reasoningChanged {
body = normalizedReasoningBody
originalBody = normalizedReasoningBody
requestView = newOpenAIRequestView(normalizedReasoningBody)
reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
originalModel = reqModel
}
sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body)
if sanitizeErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr)
@@ -458,6 +458,67 @@ func openAIRequestBodyHasTools(body []byte) bool {
return false
}
// normalizeOpenAIResponsesReasoningContentReplay removes non-portable
// reasoning.content arrays before history is sent to a real OpenAI Responses
// endpoint. Compatible providers may return visible reasoning blocks there,
// while OpenAI accepts only an empty array when the item is replayed.
//
// Keep the reasoning item and its portable fields (summary, encrypted_content,
// ids, and opaque extensions). Callers scope this normalization to OpenAI
// destinations; compatible providers may still consume their own content.
func normalizeOpenAIResponsesReasoningContentReplay(body []byte) ([]byte, bool, error) {
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body, false, nil
}
needsNormalization := false
input.ForEach(func(_, item gjson.Result) bool {
if strings.TrimSpace(item.Get("type").String()) != "reasoning" {
return true
}
content := item.Get("content")
if content.IsArray() && len(content.Array()) > 0 {
needsNormalization = true
return false
}
return true
})
if !needsNormalization {
return body, false, nil
}
var reqBody map[string]any
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
return body, false, fmt.Errorf("normalize OpenAI reasoning content replay: %w", err)
}
items, ok := reqBody["input"].([]any)
if !ok {
return body, false, nil
}
changed := false
for _, rawItem := range items {
item, ok := rawItem.(map[string]any)
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "reasoning" {
continue
}
content, ok := item["content"].([]any)
if !ok || len(content) == 0 {
continue
}
delete(item, "content")
changed = true
}
if !changed {
return body, false, nil
}
normalized, err := marshalOpenAIUpstreamJSON(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize normalized OpenAI reasoning content replay: %w", err)
}
return normalized, true, nil
}
func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) {
if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False {
return body, false, nil
@@ -1018,6 +1079,12 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
return body, false, err
}
}
if next, normalizedReasoningContent, err := normalizeOpenAIResponsesReasoningContentReplay(normalized); err != nil {
return body, false, err
} else if normalizedReasoningContent {
normalized = next
changed = true
}
if account.IsOpenAIApiKey() {
if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, responsesLite); err != nil {
return body, false, err
@@ -353,3 +353,60 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAddition
require.False(t, changed)
require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type)
}
func TestNormalizeOpenAIResponsesReasoningContentReplayStripsCrossProviderArray(t *testing.T) {
body := []byte(`{"model":"gpt-5.6-sol","input":[` +
`{"type":"message","role":"user","content":"one"},` +
`{"type":"message","role":"assistant","content":"two"},` +
`{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},` +
`{"type":"function_call_output","call_id":"call_1","output":"ok"},` +
`{"type":"message","role":"user","content":"five"},` +
`{"type":"reasoning","id":"rs_provider","summary":[{"type":"summary_text","text":"portable"}],"content":[{"type":"reasoning_text","text":"visible reasoning"}],"opaque":9007199254740993},` +
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}` +
`]}`)
normalized, changed, err := normalizeOpenAIResponsesReasoningContentReplay(body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "reasoning", gjson.GetBytes(normalized, "input.5.type").String())
require.False(t, gjson.GetBytes(normalized, "input.5.content").Exists())
require.Equal(t, "portable", gjson.GetBytes(normalized, "input.5.summary.0.text").String())
require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.5.opaque").Raw)
require.Equal(t, "answer", gjson.GetBytes(normalized, "input.6.content.0.text").String())
}
func TestNormalizeOpenAIResponsesReasoningContentReplayKeepsPortableShapes(t *testing.T) {
for _, body := range []string{
`{"input":[{"type":"reasoning","summary":[]}]}`,
`{"input":[{"type":"reasoning","content":[],"summary":[]}]}`,
`{"input":[{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`,
} {
normalized, changed, err := normalizeOpenAIResponsesReasoningContentReplay([]byte(body))
require.NoError(t, err)
require.False(t, changed)
require.JSONEq(t, body, string(normalized))
}
}
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyStripsReasoningContentOnlyForOpenAI(t *testing.T) {
body := []byte(`{"type":"response.create","model":"gpt-5.6-sol","store":true,"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"keep"}],"content":[{"type":"reasoning_text","text":"remove"}]}]}`)
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
Platform: PlatformOpenAI,
Type: accountType,
}, false)
require.NoError(t, err)
require.True(t, changed)
require.False(t, gjson.GetBytes(normalized, "input.0.content").Exists())
require.Equal(t, "keep", gjson.GetBytes(normalized, "input.0.summary.0.text").String())
}
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
Platform: PlatformZhipu,
Type: AccountTypeAPIKey,
}, false)
require.NoError(t, err)
require.False(t, changed)
require.JSONEq(t, string(body), string(normalized))
}
@@ -538,6 +538,34 @@ func TestOpenAIGatewayService_APIKeyStripsAllIndexedNamespacesBeforeFirstForward
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists())
}
func TestOpenAIGatewayServiceProactivelyStripsCrossProviderReasoningContent(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"store":true,"input":[` +
`{"type":"message","role":"user","content":"one"},` +
`{"type":"message","role":"assistant","content":"two"},` +
`{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},` +
`{"type":"function_call_output","call_id":"call_1","output":"ok"},` +
`{"type":"message","role":"user","content":"five"},` +
`{"type":"reasoning","summary":[{"type":"summary_text","text":"keep"}],"content":[{"type":"reasoning_text","text":"remove"}]}` +
`]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
context.Background(),
newOpenAIRejectedFieldTestContext(body),
newOpenAIRejectedFieldTestAccount(),
body,
)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 1, "reasoning content should be normalized before the first upstream request")
require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[0], "input.5.type").String())
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.5.content").Exists())
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[0], "input.5.summary.0.text").String())
}
func TestOpenAIGatewayService_OpenAIHTTPStripsInputNamespacesBeforeFirstForward(t *testing.T) {
accounts := []struct {
name string