From fe217258653bf924e159c6b36ddaceb0af28b011 Mon Sep 17 00:00:00 2001 From: Ouyang Xingyuan Date: Thu, 30 Jul 2026 15:15:51 +0800 Subject: [PATCH] fix(openai): recover stale encrypted compaction Reason: - Responses retries can carry account-bound encrypted compaction items that OpenAI rejects with invalid_encrypted_content. Changes: - Drop encrypted compaction and compaction_summary items only during the existing recovery retry. - Preserve unencrypted compaction items and cover HTTP and WebSocket recovery paths. --- .../service/openai_gateway_request_body.go | 9 +++- ...nai_gateway_request_body_reasoning_test.go | 41 +++++++++++++++++ .../openai_gateway_service_hotpath_test.go | 46 +++++++++++++++++++ .../openai_ws_protocol_forward_test.go | 3 +- 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index fdd4019119..099231bc0f 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -132,7 +132,14 @@ func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep } itemType, _ := inputItem["type"].(string) - if strings.TrimSpace(itemType) != "reasoning" { + switch strings.TrimSpace(itemType) { + case "compaction", "compaction_summary": + if _, encrypted := inputItem["encrypted_content"]; encrypted { + return nil, true, false + } + return item, false, true + case "reasoning": + default: return item, false, true } 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 ad14f4d8d8..dc3f2d719d 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -99,6 +99,47 @@ func TestTrimOpenAIEncryptedReasoningItems_NoReasoningItems(t *testing.T) { assert.False(t, changed) } +func TestTrimOpenAIEncryptedReasoningItems_Compaction(t *testing.T) { + tests := []struct { + name string + itemType string + encrypted bool + changed bool + }{ + {name: "compaction", itemType: "compaction", encrypted: true, changed: true}, + {name: "compaction summary", itemType: "compaction_summary", encrypted: true, changed: true}, + {name: "unencrypted compaction", itemType: "compaction", encrypted: false, changed: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item := map[string]any{"type": tt.itemType, "id": "cmp_stale"} + if tt.encrypted { + item["encrypted_content"] = "gAAA" + } + reqBody := map[string]any{"input": []any{ + item, + map[string]any{"type": "message", "content": "hi"}, + }} + + changed := trimOpenAIEncryptedReasoningItems(reqBody) + assert.Equal(t, tt.changed, changed) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.NotEmpty(t, input) + first, ok := input[0].(map[string]any) + require.True(t, ok) + if tt.changed { + require.Len(t, input, 1) + assert.Equal(t, "message", first["type"]) + return + } + require.Len(t, input, 2) + assert.Equal(t, tt.itemType, first["type"]) + }) + } +} + func TestSanitizeOpenAICrossModeFailoverReasoning_DropsWholeEncryptedItem(t *testing.T) { body := []byte(`{"model":"gpt-5.1","input":[` + `{"type":"message","role":"user","content":"hi"},` + diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index c8d72f0b81..a51f58bf56 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -593,6 +593,52 @@ func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDoesNotDecodeBeforeError( require.Equal(t, "summary_text", gjson.GetBytes(upstream.bodies[1], "input.0.summary.0.type").String()) } +func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDropsCompaction(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := &httpUpstreamRecorder{ + responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_encrypted_content","type":"invalid_request_error","message":"bad encrypted content"}}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)), + }, + }, + } + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := &Account{ + ID: 10, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://example.com", + }, + Extra: map[string]any{"use_responses_api": true}, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + + body := []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"id":"cmp_stale","type":"compaction","encrypted_content":"gAAA"},{"type":"message","content":[{"type":"input_text","text":"hi"}]}]}`) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "compaction", gjson.GetBytes(upstream.bodies[0], "input.0.type").String()) + require.Equal(t, "message", gjson.GetBytes(upstream.bodies[1], "input.0.type").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1").Exists()) +} + func TestOpenAIGatewayService_Forward_CodexSparkRejectsEscapedInputImage(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &httpUpstreamRecorder{ diff --git a/backend/internal/service/openai_ws_protocol_forward_test.go b/backend/internal/service/openai_ws_protocol_forward_test.go index b9e62c3f9d..32779a23f3 100644 --- a/backend/internal/service/openai_ws_protocol_forward_test.go +++ b/backend/internal/service/openai_ws_protocol_forward_test.go @@ -1537,7 +1537,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t }, } - body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":[{"type":"reasoning","encrypted_content":"gAAA"},{"type":"input_text","text":"hello"}]}`) + body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":[{"type":"reasoning","encrypted_content":"gAAA"},{"type":"compaction","encrypted_content":"cAAA"},{"type":"input_text","text":"hello"}]}`) result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) @@ -1553,6 +1553,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t require.Len(t, requests, 2) require.True(t, gjson.GetBytes(requests[0], "previous_response_id").Exists(), "首轮请求应保留 previous_response_id") require.True(t, gjson.GetBytes(requests[0], `input.0.encrypted_content`).Exists(), "首轮请求应保留 encrypted reasoning") + require.True(t, gjson.GetBytes(requests[0], `input.1.encrypted_content`).Exists(), "首轮请求应保留 encrypted compaction") require.False(t, gjson.GetBytes(requests[1], "previous_response_id").Exists(), "恢复重试应移除 previous_response_id") require.False(t, gjson.GetBytes(requests[1], `input.0.encrypted_content`).Exists(), "恢复重试应移除 encrypted reasoning item") require.Equal(t, "input_text", gjson.GetBytes(requests[1], `input.0.type`).String())