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.
This commit is contained in:
Ouyang Xingyuan
2026-07-31 21:21:13 +08:00
parent 5a6143097d
commit fe21725865
4 changed files with 97 additions and 2 deletions
@@ -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
}
@@ -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"},` +
@@ -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{
@@ -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())