From c5d9d57940ff5f4566fc9db82f9143d64b8486a6 Mon Sep 17 00:00:00 2001 From: ListenCodes <57614025+ListenCodes@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:15:58 +0800 Subject: [PATCH 1/2] fix(openai): sanitize API-key responses item IDs --- .../service/openai_codex_transform.go | 22 +------ .../openai_gateway_apikey_item_id_test.go | 62 +++++++++++++++++++ .../service/openai_gateway_forward.go | 13 ++++ .../service/openai_responses_item_id.go | 61 ++++++++++++++++++ 4 files changed, 139 insertions(+), 19 deletions(-) create mode 100644 backend/internal/service/openai_gateway_apikey_item_id_test.go create mode 100644 backend/internal/service/openai_responses_item_id.go diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 2d6766af4..c89fd038d 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -1494,25 +1494,9 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an if !opts.PreserveReferences { ensureCopy() delete(newItem, "id") - } else if isCodexToolCallInputType(typ) { - // 续链模式下保留 id 以维持上下文引用,但 function_call 等 - // call-input 类 item 的 id 必须以 "fc" 开头(上游校验 - // "Expected an ID that begins with 'fc'")。item_* 形式的 id - // 来自客户端回放,需要删除。 - // 注意:function_call_output 等 output 类的 id 无此约束,不动。 - if id, ok := m["id"].(string); ok && id != "" && !strings.HasPrefix(id, "fc") { - ensureCopy() - delete(newItem, "id") - } - } else if typ == "message" { - // 同理,message 类 item 的 id 必须以 "msg" 开头(上游校验 - // "Expected an ID that begins with 'msg'")。item_* 形式的 id - // 来自客户端回放,需要删除。 - // 注意:不改写成 msg_*,改写出的 id 未必对应真实的上游对象。 - if id, ok := m["id"].(string); ok && id != "" && !strings.HasPrefix(id, "msg") { - ensureCopy() - delete(newItem, "id") - } + } else if id, ok := m["id"].(string); ok && shouldStripOpenAIResponsesInputItemID(typ, id) { + ensureCopy() + delete(newItem, "id") } filtered = append(filtered, newItem) diff --git a/backend/internal/service/openai_gateway_apikey_item_id_test.go b/backend/internal/service/openai_gateway_apikey_item_id_test.go new file mode 100644 index 000000000..26dfc9349 --- /dev/null +++ b/backend/internal/service/openai_gateway_apikey_item_id_test.go @@ -0,0 +1,62 @@ +//go:build unit + +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`, + )), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := newOpenAIImageGenerationControlTestAccount() + account.Extra = map[string]any{"openai_passthrough": true} + + body := []byte(`{ + "model":"gpt-5.6-sol", + "stream":false, + "input":[ + {"type":"message","id":"item_bad_message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}, + {"type":"function_call","id":"item_bad_call","call_id":"call_123","name":"exec_command","arguments":"{}"}, + {"type":"message","id":"msg_valid","role":"user","content":[{"type":"input_text","text":"continue"}]}, + {"type":"function_call","id":"fc_valid","call_id":"call_456","name":"apply_patch","arguments":"{}"}, + {"type":"function_call_output","id":"item_output","call_id":"call_123","output":"done"}, + {"type":"web_search_call","id":"item_unconstrained"} + ] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + + forwarded := upstream.lastBody + require.False(t, gjson.GetBytes(forwarded, "input.0.id").Exists()) + require.Equal(t, "hello", gjson.GetBytes(forwarded, "input.0.content.0.text").String()) + require.False(t, gjson.GetBytes(forwarded, "input.1.id").Exists()) + require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.1.call_id").String()) + require.Equal(t, "exec_command", gjson.GetBytes(forwarded, "input.1.name").String()) + require.Equal(t, "{}", gjson.GetBytes(forwarded, "input.1.arguments").String()) + require.Equal(t, "msg_valid", gjson.GetBytes(forwarded, "input.2.id").String()) + require.Equal(t, "fc_valid", gjson.GetBytes(forwarded, "input.3.id").String()) + require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.4.id").String()) + 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()) +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 7a42a445e..8c48c2f99 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -85,6 +85,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) } + if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey { + sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body) + if sanitizeErr != nil { + return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr) + } + if changed { + body = sanitizedBody + originalBody = sanitizedBody + requestView = newOpenAIRequestView(sanitizedBody) + reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey + originalModel = reqModel + } + } compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body) setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge) diff --git a/backend/internal/service/openai_responses_item_id.go b/backend/internal/service/openai_responses_item_id.go new file mode 100644 index 000000000..5f5ce04be --- /dev/null +++ b/backend/internal/service/openai_responses_item_id.go @@ -0,0 +1,61 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Invalid replayed IDs are removed rather than rewritten because a fabricated +// msg/fc ID may point at a different upstream object. +func shouldStripOpenAIResponsesInputItemID(itemType, id string) bool { + if id == "" { + return false + } + if itemType == "message" { + return !strings.HasPrefix(id, "msg") + } + if isCodexToolCallInputType(itemType) { + return !strings.HasPrefix(id, "fc") + } + return false +} + +func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) { + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return body, false, nil + } + + paths := make([]string, 0) + 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)) + } + return true + }) + if len(paths) == 0 { + 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) + } + } + return sanitized, true, nil +} From 1891faa68d673b02c04cc17de0d54c2726821e4d Mon Sep 17 00:00:00 2001 From: LIULIXING Date: Sat, 25 Jul 2026 01:40:26 +0800 Subject: [PATCH 2/2] 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 26dfc9349..b7af70ec7 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 5f5ce04be..27b7dea0f 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 }