From 1891faa68d673b02c04cc17de0d54c2726821e4d Mon Sep 17 00:00:00 2001 From: LIULIXING Date: Sat, 25 Jul 2026 01:40:26 +0800 Subject: [PATCH] fix(openai): keep item ID sanitization linear --- .../openai_gateway_apikey_item_id_test.go | 28 +++++++++++ .../service/openai_responses_item_id.go | 49 +++++++++++++------ 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/backend/internal/service/openai_gateway_apikey_item_id_test.go b/backend/internal/service/openai_gateway_apikey_item_id_test.go index 26dfc9349e..b7af70ec75 100644 --- a/backend/internal/service/openai_gateway_apikey_item_id_test.go +++ b/backend/internal/service/openai_gateway_apikey_item_id_test.go @@ -4,8 +4,10 @@ package service import ( "context" + "fmt" "io" "net/http" + "runtime" "strings" "testing" @@ -60,3 +62,29 @@ func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *tes require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.4.call_id").String()) require.Equal(t, "item_unconstrained", gjson.GetBytes(forwarded, "input.5.id").String()) } + +func TestSanitizeOpenAIResponsesInputItemIDs_AllocationGrowthIsLinear(t *testing.T) { + makeBody := func(itemCount int) []byte { + items := make([]string, itemCount) + for i := range items { + items[i] = fmt.Sprintf(`{"type":"message","id":"item_%d","role":"user","content":[{"type":"input_text","text":"hello"}]}`, i) + } + return []byte(`{"model":"gpt-5.6-sol","input":[` + strings.Join(items, ",") + `]}`) + } + allocatedBytes := func(body []byte) uint64 { + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body) + runtime.ReadMemStats(&after) + require.NoError(t, err) + require.True(t, changed) + require.NotEmpty(t, sanitized) + return after.TotalAlloc - before.TotalAlloc + } + + smallAllocated := allocatedBytes(makeBody(20)) + largeAllocated := allocatedBytes(makeBody(200)) + require.Less(t, largeAllocated, smallAllocated*30, + "10x more input items must not cause quadratic whole-body allocation growth") +} diff --git a/backend/internal/service/openai_responses_item_id.go b/backend/internal/service/openai_responses_item_id.go index 5f5ce04be2..27b7dea0f5 100644 --- a/backend/internal/service/openai_responses_item_id.go +++ b/backend/internal/service/openai_responses_item_id.go @@ -29,33 +29,50 @@ func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) { return body, false, nil } - paths := make([]string, 0) + items := make([][]byte, 0) + changed := false + var sanitizeErr error index := 0 input.ForEach(func(_, item gjson.Result) bool { currentIndex := index index++ - if !item.IsObject() { - return true - } - itemType := item.Get("type") - id := item.Get("id") - if itemType.Type == gjson.String && id.Type == gjson.String && - shouldStripOpenAIResponsesInputItemID(itemType.String(), id.String()) { - paths = append(paths, fmt.Sprintf("input.%d.id", currentIndex)) + itemBody := []byte(item.Raw) + if item.IsObject() { + itemType := item.Get("type") + id := item.Get("id") + if itemType.Type == gjson.String && id.Type == gjson.String && + shouldStripOpenAIResponsesInputItemID(itemType.String(), id.String()) { + itemBody, sanitizeErr = sjson.DeleteBytes(itemBody, "id") + if sanitizeErr != nil { + sanitizeErr = fmt.Errorf("delete input.%d.id: %w", currentIndex, sanitizeErr) + return false + } + changed = true + } } + items = append(items, itemBody) return true }) - if len(paths) == 0 { + if sanitizeErr != nil { + return nil, false, sanitizeErr + } + if !changed { return body, false, nil } - sanitized := body - for _, path := range paths { - var err error - sanitized, err = sjson.DeleteBytes(sanitized, path) - if err != nil { - return nil, false, fmt.Errorf("delete %s: %w", path, err) + rebuiltInput := make([]byte, 0, len(input.Raw)) + rebuiltInput = append(rebuiltInput, '[') + for i, item := range items { + if i > 0 { + rebuiltInput = append(rebuiltInput, ',') } + rebuiltInput = append(rebuiltInput, item...) + } + rebuiltInput = append(rebuiltInput, ']') + + sanitized, err := sjson.SetRawBytes(body, "input", rebuiltInput) + if err != nil { + return nil, false, fmt.Errorf("replace sanitized input: %w", err) } return sanitized, true, nil }