From 15f469c86b77378a493aed688aa755ab2daa5a3f Mon Sep 17 00:00:00 2001 From: SPHINX998 <51872346+SPHINX998@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:00:22 +0800 Subject: [PATCH] fix(gateway): ignore negative content block index in buffered Anthropic paths The buffered Anthropic aggregators index finalResp.Content with the upstream content_block_delta index after checking only idx < len. A malformed or third-party Anthropic-compatible upstream that sends a negative index makes the request goroutine panic, so the client gets no response and the forward never returns a result to record usage. Add the lower bound in all four buffered aggregators (Chat Completions and Responses, on both the Anthropic gateway and the OpenAI native Anthropic paths); the out-of-range delta is skipped like an index past the end. Co-Authored-By: Claude Opus 5.5 --- .../anthropic_buffered_negative_index_test.go | 97 +++++++++++++++++++ .../gateway_forward_as_chat_completions.go | 2 +- .../service/gateway_forward_as_responses.go | 2 +- ...teway_chat_completions_anthropic_native.go | 2 +- ...enai_gateway_responses_anthropic_native.go | 2 +- 5 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 backend/internal/service/anthropic_buffered_negative_index_test.go diff --git a/backend/internal/service/anthropic_buffered_negative_index_test.go b/backend/internal/service/anthropic_buffered_negative_index_test.go new file mode 100644 index 0000000000..c5f4a8511e --- /dev/null +++ b/backend/internal/service/anthropic_buffered_negative_index_test.go @@ -0,0 +1,97 @@ +package service + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// anthropicSSEWithNegativeBlockIndex is a buffered Anthropic stream in which a +// malformed upstream sends content_block_delta with a negative index. The +// buffered aggregators only bounded the index from above, so indexing +// finalResp.Content with it panicked. +func anthropicSSEWithNegativeBlockIndex() string { + return strings.Join([]string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_neg","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4.5","usage":{"input_tokens":10}}}`, + ``, + `event: content_block_start`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + ``, + `event: content_block_delta`, + `data: {"type":"content_block_delta","index":-1,"delta":{"type":"text_delta","text":"IGNORED"}}`, + ``, + `event: content_block_delta`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`, + ``, + `event: message_stop`, + `data: {"type":"message_stop"}`, + ``, + }, "\n") +} + +func TestAnthropicBufferedAggregators_IgnoreNegativeBlockIndex(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + run func(resp *http.Response, c *gin.Context) error + }{ + { + name: "gateway chat completions", + run: func(resp *http.Response, c *gin.Context) error { + _, err := (&GatewayService{}).handleCCBufferedFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", nil, time.Now()) + return err + }, + }, + { + name: "gateway responses", + run: func(resp *http.Response, c *gin.Context) error { + _, err := (&GatewayService{}).handleResponsesBufferedStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + return err + }, + }, + { + name: "openai native anthropic chat completions", + run: func(resp *http.Response, c *gin.Context) error { + _, err := newNativeAnthropicHangTestService(5).handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now()) + return err + }, + }, + { + name: "openai native anthropic responses", + run: func(resp *http.Response, c *gin.Context) error { + _, err := newNativeAnthropicHangTestService(5).handleResponsesBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + return err + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(anthropicSSEWithNegativeBlockIndex())), + } + + var err error + require.NotPanics(t, func() { err = tt.run(resp, c) }) + require.NoError(t, err) + require.Contains(t, rec.Body.String(), "Hello") + require.NotContains(t, rec.Body.String(), "IGNORED") + }) + } +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 800bb81259..5113fc6d09 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -280,7 +280,7 @@ func (s *GatewayService) handleCCBufferedFromAnthropic( } if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { idx := *event.Index - if idx < len(finalResp.Content) { + if idx >= 0 && idx < len(finalResp.Content) { switch event.Delta.Type { case "text_delta": finalResp.Content[idx].Text += event.Delta.Text diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index c360b95d23..db8033ebe4 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -438,7 +438,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( } if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { idx := *event.Index - if idx < len(finalResp.Content) { + if idx >= 0 && idx < len(finalResp.Content) { switch event.Delta.Type { case "text_delta": finalResp.Content[idx].Text += event.Delta.Text diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index 5a937eff40..6c0affe2bf 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -241,7 +241,7 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( } if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { idx := *event.Index - if idx < len(finalResp.Content) { + if idx >= 0 && idx < len(finalResp.Content) { switch event.Delta.Type { case "text_delta": finalResp.Content[idx].Text += event.Delta.Text diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 69bbd73039..09278d2914 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -242,7 +242,7 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( } if event.Type == "content_block_delta" && event.Delta != nil && finalResp != nil && event.Index != nil { idx := *event.Index - if idx < len(finalResp.Content) { + if idx >= 0 && idx < len(finalResp.Content) { switch event.Delta.Type { case "text_delta": finalResp.Content[idx].Text += event.Delta.Text