diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 6487385000..ebf12675c9 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -173,6 +173,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) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 6272875cc5..23cc01d670 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -395,6 +395,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 @@ -955,6 +1016,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 diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index 543d294880..cbe24815cd 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -293,3 +293,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)) +} 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 9f794b1e57..4ed3e3ba22 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry_test.go +++ b/backend/internal/service/openai_responses_rejected_field_retry_test.go @@ -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