Skip to content

Commit 9dd58ed

Browse files
authored
Merge branch 'main' into fix/tolerate-truncated-gzip-eof
2 parents 5e13670 + 4ae1ec4 commit 9dd58ed

11 files changed

Lines changed: 230 additions & 34 deletions

File tree

internal/extproc/processor_impl.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -267,12 +267,10 @@ func (r *routerProcessor[ReqT, RespT, RespChunkT, EndpointSpecT]) ProcessRequest
267267
Header: &corev3.HeaderValue{Key: internalapi.ModelNameHeaderKeyDefault, RawValue: []byte(originalModel)},
268268
})
269269
originalPath := r.requestHeaders[":path"]
270-
if r.requestHeaders[originalPathHeader] == "" {
271-
r.requestHeaders[originalPathHeader] = originalPath
272-
additionalHeaders = append(additionalHeaders, &corev3.HeaderValueOption{
273-
Header: &corev3.HeaderValue{Key: originalPathHeader, RawValue: []byte(originalPath)},
274-
})
275-
}
270+
r.requestHeaders[originalPathHeader] = originalPath
271+
additionalHeaders = append(additionalHeaders, &corev3.HeaderValueOption{
272+
Header: &corev3.HeaderValue{Key: originalPathHeader, RawValue: []byte(originalPath)},
273+
})
276274
if r.requestHeaders[internalapi.EnvoyOriginalPathHeader] == "" {
277275
r.requestHeaders[internalapi.EnvoyOriginalPathHeader] = originalPath
278276
additionalHeaders = append(additionalHeaders, &corev3.HeaderValueOption{

internal/mcpproxy/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type (
3131
tracer tracingapi.MCPTracer
3232
client http.Client
3333
logRequestHeaderAttributes map[string]string
34+
maxRequestBodySize int64 // maximum allowed POST body size in bytes
3435
}
3536

3637
mcpProxyConfig struct {

internal/mcpproxy/handlers.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,18 @@ func (m *mcpRequestContext) servePOST(w http.ResponseWriter, r *http.Request) {
276276
}
277277
}
278278

279-
body, err := io.ReadAll(r.Body)
279+
limit := m.maxRequestBodySize
280+
if limit <= 0 {
281+
limit = defaultMaxRequestBodySize
282+
}
283+
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
280284
if err != nil {
285+
var maxBytesErr *http.MaxBytesError
286+
if errors.As(err, &maxBytesErr) {
287+
errType = metrics.MCPErrorInternal
288+
onErrorResponse(w, http.StatusRequestEntityTooLarge, "request body too large")
289+
return
290+
}
281291
errType = metrics.MCPErrorInternal
282292
onErrorResponse(w, http.StatusBadRequest, err.Error())
283293
return

internal/mcpproxy/handlers_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,20 @@ func TestServePOST_InvalidJSONRPC(t *testing.T) {
168168
require.Contains(t, rr.Body.String(), "invalid JSON-RPC message")
169169
}
170170

171+
func TestServePOST_OversizedBody(t *testing.T) {
172+
proxy := newTestMCPProxy()
173+
proxy.maxRequestBodySize = 16 // tiny limit to exercise the guard without allocating a large buffer.
174+
175+
body := strings.NewReader(`{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}`) // > 16 bytes
176+
req := httptest.NewRequest(http.MethodPost, "/mcp", body)
177+
rr := httptest.NewRecorder()
178+
179+
proxy.servePOST(rr, req)
180+
181+
require.Equal(t, http.StatusRequestEntityTooLarge, rr.Code)
182+
require.Contains(t, rr.Body.String(), "request body too large")
183+
}
184+
171185
func TestServePOST_InvalidSessionID(t *testing.T) {
172186
proxy := newTestMCPProxy()
173187
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(`{"jsonrpc":"2.0","method":"tools/call","params":{"name":"test-tool"},"id":"1"}`))

internal/mcpproxy/mcpproxy.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"log/slog"
1515
"maps"
1616
"net/http"
17+
"os"
18+
"strconv"
1719
"strings"
1820
"sync"
1921
"time"
@@ -39,6 +41,20 @@ type mcpRequestContext struct {
3941
perBackendMetricsRecorded bool
4042
}
4143

44+
// defaultMaxRequestBodySize is the default maximum allowed POST body size in bytes (4 MiB).
45+
const defaultMaxRequestBodySize = 4 * 1024 * 1024
46+
47+
// getMaxRequestBodySize returns the configured POST body limit from the environment variable,
48+
// falling back to 4 MiB if the variable is unset or invalid.
49+
func getMaxRequestBodySize() int64 {
50+
if v, ok := os.LookupEnv("MCP_PROXY_MAX_REQUEST_BODY_SIZE"); ok {
51+
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
52+
return n
53+
}
54+
}
55+
return defaultMaxRequestBodySize
56+
}
57+
4258
// NewMCPProxy creates a new MCPProxy instance.
4359
func NewMCPProxy(l *slog.Logger, mcpMetrics metrics.MCPMetrics, tracer tracingapi.MCPTracer, sessionCrypto SessionCrypto, logRequestHeaderAttributes map[string]string) (*ProxyConfig, *http.ServeMux, error) {
4460
toolChangeSignaler := newMultiWatcherSignaler() // used to signal changes to all active sessions.
@@ -49,6 +65,7 @@ func NewMCPProxy(l *slog.Logger, mcpMetrics metrics.MCPMetrics, tracer tracingap
4965
l: l,
5066
client: http.Client{}, // No timeout as it's enforced at Envoy level.
5167
logRequestHeaderAttributes: maps.Clone(logRequestHeaderAttributes),
68+
maxRequestBodySize: getMaxRequestBodySize(),
5269
}
5370
mux := http.NewServeMux()
5471
mux.HandleFunc(

internal/mcpproxy/mcpproxy_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,25 @@ func (f *fakeTracer) StartSpanAndInjectMeta(context.Context, *jsonrpc.Request, m
6060

6161
var noopTracer = tracingapi.NoopMCPTracer{}
6262

63+
func TestGetMaxRequestBodySize(t *testing.T) {
64+
for _, tc := range []struct {
65+
name string
66+
envValue string
67+
want int64
68+
}{
69+
{name: "default when env var not set", envValue: "", want: defaultMaxRequestBodySize},
70+
{name: "custom value from env var", envValue: "1048576", want: 1048576},
71+
{name: "default when env var is not a number", envValue: "not-a-number", want: defaultMaxRequestBodySize},
72+
{name: "default when env var is zero", envValue: "0", want: defaultMaxRequestBodySize},
73+
{name: "default when env var is negative", envValue: "-1", want: defaultMaxRequestBodySize},
74+
} {
75+
t.Run(tc.name, func(t *testing.T) {
76+
t.Setenv("MCP_PROXY_MAX_REQUEST_BODY_SIZE", tc.envValue)
77+
require.Equal(t, tc.want, getMaxRequestBodySize())
78+
})
79+
}
80+
}
81+
6382
func TestNewMCPProxy(t *testing.T) {
6483
l := slog.Default()
6584
proxy, mux, err := NewMCPProxy(l, stubMetrics{}, noopTracer, NewPBKDF2AesGcmSessionCrypto("test", 100), nil)

internal/pprof/pprof.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func run(ctx context.Context, l *log.Logger) {
4040
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
4141
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
4242
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
43-
server := &http.Server{Addr: ":" + pprofPort, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
43+
server := &http.Server{Addr: "127.0.0.1:" + pprofPort, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
4444
go func() {
4545
l.Printf("starting pprof server on port %s", pprofPort)
4646
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {

internal/translator/anthropic_helper.go

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -966,8 +966,8 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat
966966
&u.CacheReadInputTokens,
967967
&u.CacheCreationInputTokens,
968968
)
969-
// For message_start, we store the initial usage but don't add to the accumulated
970-
// The message_delta event will contain the final totals
969+
// Set all input token counts (input, cache read, cache creation) from message_start.
970+
// message_delta may also contain these fields but only output_tokens is used from it.
971971
if input, ok := usage.InputTokens(); ok {
972972
p.tokenUsage.SetInputTokens(input)
973973
}
@@ -1061,17 +1061,6 @@ func (p *anthropicStreamParser) handleAnthropicStreamEvent(eventType []byte, dat
10611061
if output, ok := usage.OutputTokens(); ok {
10621062
p.tokenUsage.AddOutputTokens(output)
10631063
}
1064-
// Update input tokens to include any cache tokens from delta
1065-
if cached, ok := usage.CachedInputTokens(); ok {
1066-
p.tokenUsage.AddInputTokens(cached)
1067-
// Accumulate any additional cache tokens from delta
1068-
p.tokenUsage.AddCachedInputTokens(cached)
1069-
}
1070-
if cacheCreation, ok := usage.CacheCreationInputTokens(); ok {
1071-
p.tokenUsage.AddInputTokens(cacheCreation)
1072-
// Accumulate cache creation tokens
1073-
p.tokenUsage.AddCacheCreationInputTokens(cacheCreation)
1074-
}
10751064
p.tokenUsage.SetReasoningTokens(uint32(u.OutputTokensDetails.ThinkingTokens)) //nolint:gosec
10761065
if event.Delta.StopReason != "" {
10771066
p.stopReason = event.Delta.StopReason

internal/translator/anthropic_helper_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@ package translator
77

88
import (
99
"fmt"
10+
"strings"
1011
"testing"
1112

1213
"github.com/anthropics/anthropic-sdk-go"
1314
"github.com/anthropics/anthropic-sdk-go/shared/constant"
15+
"github.com/stretchr/testify/assert"
1416
"github.com/stretchr/testify/require"
1517
"k8s.io/utils/ptr"
1618

1719
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
1820
"github.com/envoyproxy/ai-gateway/internal/internalapi"
21+
"github.com/envoyproxy/ai-gateway/internal/metrics"
1922
)
2023

2124
// mockErrorReader is a helper for testing io.Reader failures.
@@ -1157,3 +1160,148 @@ func TestBuildAnthropicParamsWithReasoningEffort(t *testing.T) {
11571160
require.Equal(t, anthropic.OutputConfigEffort(""), params.OutputConfig.Effort)
11581161
})
11591162
}
1163+
1164+
func TestAnthropicStreamParser_StreamingTokenUsage(t *testing.T) {
1165+
tests := []struct {
1166+
name string
1167+
events string
1168+
expectedInputTokens uint32
1169+
expectedOutputTokens uint32
1170+
expectedTotalTokens uint32
1171+
expectedCachedTokens uint32
1172+
expectedCacheCreationTokens uint32
1173+
}{
1174+
{
1175+
name: "with cache tokens",
1176+
events: `event: message_start
1177+
data: {"type": "message_start", "message": {"id": "msg_abc123", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 678, "cache_read_input_tokens": 13363, "cache_creation_input_tokens": 0, "output_tokens": 1}}}
1178+
1179+
event: content_block_start
1180+
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
1181+
1182+
event: content_block_delta
1183+
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hi"}}
1184+
1185+
event: content_block_stop
1186+
data: {"type": "content_block_stop", "index": 0}
1187+
1188+
event: message_delta
1189+
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 678, "cache_read_input_tokens": 13363, "cache_creation_input_tokens": 0, "output_tokens": 5}}
1190+
1191+
event: message_stop
1192+
data: {"type": "message_stop"}
1193+
1194+
`,
1195+
expectedInputTokens: 14041, // 678 + 13363 + 0
1196+
expectedOutputTokens: 5,
1197+
expectedTotalTokens: 14046, // 14041 + 5
1198+
expectedCachedTokens: 13363,
1199+
expectedCacheCreationTokens: 0,
1200+
},
1201+
{
1202+
name: "without cache tokens",
1203+
events: `event: message_start
1204+
data: {"type": "message_start", "message": {"id": "msg_abc456", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 100, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "output_tokens": 1}}}
1205+
1206+
event: content_block_start
1207+
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
1208+
1209+
event: content_block_delta
1210+
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}}
1211+
1212+
event: content_block_stop
1213+
data: {"type": "content_block_stop", "index": 0}
1214+
1215+
event: message_delta
1216+
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 100, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "output_tokens": 10}}
1217+
1218+
event: message_stop
1219+
data: {"type": "message_stop"}
1220+
1221+
`,
1222+
expectedInputTokens: 100,
1223+
expectedOutputTokens: 10,
1224+
expectedTotalTokens: 110,
1225+
expectedCachedTokens: 0,
1226+
expectedCacheCreationTokens: 0,
1227+
},
1228+
{
1229+
name: "with cache creation tokens",
1230+
events: `event: message_start
1231+
data: {"type": "message_start", "message": {"id": "msg_abc789", "type": "message", "role": "assistant", "content": [], "model": "claude-sonnet-4-6", "usage": {"input_tokens": 200, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 5000, "output_tokens": 1}}}
1232+
1233+
event: content_block_start
1234+
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
1235+
1236+
event: content_block_delta
1237+
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Response"}}
1238+
1239+
event: content_block_stop
1240+
data: {"type": "content_block_stop", "index": 0}
1241+
1242+
event: message_delta
1243+
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"input_tokens": 200, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 5000, "output_tokens": 8}}
1244+
1245+
event: message_stop
1246+
data: {"type": "message_stop"}
1247+
1248+
`,
1249+
expectedInputTokens: 5200, // 200 + 5000 + 0
1250+
expectedOutputTokens: 8,
1251+
expectedTotalTokens: 5208, // 5200 + 8
1252+
expectedCachedTokens: 0,
1253+
expectedCacheCreationTokens: 5000,
1254+
},
1255+
}
1256+
1257+
for _, tt := range tests {
1258+
t.Run(tt.name, func(t *testing.T) {
1259+
parser := newAnthropicStreamParser("claude-sonnet-4-6")
1260+
1261+
// Feed each event block separately (simulating chunked SSE delivery),
1262+
// with the last chunk marked as endOfStream.
1263+
chunks := splitSSEEvents(tt.events)
1264+
var tokenUsage metrics.TokenUsage
1265+
for i, chunk := range chunks {
1266+
endOfStream := i == len(chunks)-1
1267+
_, _, usage, _, err := parser.Process(strings.NewReader(chunk), endOfStream, nil)
1268+
require.NoError(t, err)
1269+
if endOfStream {
1270+
tokenUsage = usage
1271+
}
1272+
}
1273+
1274+
inputTokens, inputSet := tokenUsage.InputTokens()
1275+
assert.True(t, inputSet, "InputTokens should be set")
1276+
assert.Equal(t, tt.expectedInputTokens, inputTokens, "InputTokens mismatch")
1277+
1278+
outputTokens, outputSet := tokenUsage.OutputTokens()
1279+
assert.True(t, outputSet, "OutputTokens should be set")
1280+
assert.Equal(t, tt.expectedOutputTokens, outputTokens, "OutputTokens mismatch")
1281+
1282+
totalTokens, totalSet := tokenUsage.TotalTokens()
1283+
assert.True(t, totalSet, "TotalTokens should be set")
1284+
assert.Equal(t, tt.expectedTotalTokens, totalTokens, "TotalTokens mismatch")
1285+
1286+
cachedTokens, cachedSet := tokenUsage.CachedInputTokens()
1287+
assert.True(t, cachedSet, "CachedInputTokens should be set")
1288+
assert.Equal(t, tt.expectedCachedTokens, cachedTokens, "CachedInputTokens mismatch")
1289+
1290+
cacheCreation, cacheCreationSet := tokenUsage.CacheCreationInputTokens()
1291+
assert.True(t, cacheCreationSet, "CacheCreationInputTokens should be set")
1292+
assert.Equal(t, tt.expectedCacheCreationTokens, cacheCreation, "CacheCreationInputTokens mismatch")
1293+
})
1294+
}
1295+
}
1296+
1297+
func splitSSEEvents(data string) []string {
1298+
parts := strings.Split(data, "\n\n")
1299+
var events []string
1300+
for _, p := range parts {
1301+
trimmed := strings.TrimSpace(p)
1302+
if trimmed != "" {
1303+
events = append(events, p+"\n\n")
1304+
}
1305+
}
1306+
return events
1307+
}

site/src/components/HomepageFeatures/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ const FeatureList: FeatureItem[] = [
1919
),
2020
},
2121
{
22-
title: 'v0.5 Release now available',
22+
title: 'v0.7 Release now available',
2323
image: require('@site/static/img/3.png').default,
2424
description: (
2525
<>
26-
The v0.5 Release of Envoy AI Gateway is now available. See the <a href="/release-notes/v0.5">release notes</a> for more information.
26+
The v0.7 Release of Envoy AI Gateway is now available. See the <a href="/release-notes/v0.7">release notes</a> for more information.
2727
</>
2828
),
2929
},

0 commit comments

Comments
 (0)