This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 059748ce25 fix(ai): prevent token usage loss across streaming flush 
boundaries (#7730)
059748ce25 is described below

commit 059748ce25ad8dd532412657ad66f2964a3ab0d0
Author: lymerin <[email protected]>
AuthorDate: Mon Oct 19 09:33:44 2026 +0800

    fix(ai): prevent token usage loss across streaming flush boundaries (#7730)
---
 .../ai/token/limiter/AiTokenLimiterPlugin.java     | 52 ++++++++-----
 .../ai/token/limiter/AiTokenLimiterPluginTest.java | 86 ++++++++++++++++++++++
 2 files changed, 118 insertions(+), 20 deletions(-)

diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterPlugin.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterPlugin.java
index dd4e595217..3631130958 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterPlugin.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterPlugin.java
@@ -217,27 +217,38 @@ public class AiTokenLimiterPlugin extends 
AbstractShenyuPlugin {
         @Override
         @NonNull
         public Mono<Void> writeWith(@NonNull final Publisher<? extends 
DataBuffer> body) {
-            return super.writeWith(appendResponse(body));
+            return Mono.defer(() -> {
+                final BodyWriter writer = new BodyWriter();
+                final GzipStreamDecoder decoder = createDecoder();
+                return super.writeWith(appendResponse(body, writer, decoder))
+                        .doFinally(signal -> finishResponse(writer, decoder));
+            });
         }
 
         @Override
         @NonNull
         public Mono<Void> writeAndFlushWith(@NonNull final Publisher<? extends 
Publisher<? extends DataBuffer>> body) {
-            Flux<? extends Publisher<? extends DataBuffer>> intercepted = 
Flux.from(body)
-                    .map(this::appendResponse);
-            return super.writeAndFlushWith(intercepted);
+            return Mono.defer(() -> {
+                final BodyWriter writer = new BodyWriter();
+                final GzipStreamDecoder decoder = createDecoder();
+                Flux<? extends Publisher<? extends DataBuffer>> intercepted = 
Flux.from(body)
+                        .map(inner -> appendResponse(inner, writer, decoder));
+                return super.writeAndFlushWith(intercepted)
+                        .doFinally(signal -> finishResponse(writer, decoder));
+            });
         }
 
-        @NonNull
-        private Flux<? extends DataBuffer> appendResponse(final Publisher<? 
extends DataBuffer> body) {
-            BodyWriter writer = new BodyWriter();
+        private GzipStreamDecoder createDecoder() {
             HttpHeaders headers = serverHttpResponse.getHeaders();
             boolean isGzip = headers.containsKey(Constants.CONTENT_ENCODING)
                     && headers.getFirst(Constants.CONTENT_ENCODING)
                     .contains(Constants.HTTP_ACCEPT_ENCODING_GZIP);
+            return isGzip ? new GzipStreamDecoder() : null;
+        }
 
-            final GzipStreamDecoder decoder = isGzip ? new GzipStreamDecoder() 
: null;
-
+        @NonNull
+        private Flux<? extends DataBuffer> appendResponse(final Publisher<? 
extends DataBuffer> body,
+                                                        final BodyWriter 
writer, final GzipStreamDecoder decoder) {
             return Flux.<DataBuffer>from(body)
                     .doOnNext(buffer -> {
                         try (DataBuffer.ByteBufferIterator it = 
buffer.readableByteBuffers()) {
@@ -246,7 +257,7 @@ public class AiTokenLimiterPlugin extends 
AbstractShenyuPlugin {
                                 byte[] inBytes = new byte[ro.remaining()];
                                 ro.get(inBytes);
 
-                                byte[] processedBytes = isGzip ? 
decoder.decode(inBytes) : inBytes;
+                                byte[] processedBytes = 
Objects.nonNull(decoder) ? decoder.decode(inBytes) : inBytes;
                                 if (processedBytes.length > 0) {
                                     processChunk(processedBytes, writer);
                                 }
@@ -254,19 +265,20 @@ public class AiTokenLimiterPlugin extends 
AbstractShenyuPlugin {
                         } catch (Exception e) {
                             LOG.error("read dataBuffer error", e);
                         }
-                    })
-                    .doFinally(signal -> {
-                        if (Objects.nonNull(decoder)) {
-                            decoder.close();
-                        }
-                        if (!streamingUsageRecorded.get()) {
-                            String sse = writer.output();
-                            long usageTokens = extractUsageTokensFromSse(sse);
-                            tokensRecorder.accept(usageTokens);
-                        }
                     });
         }
 
+        private void finishResponse(final BodyWriter writer, final 
GzipStreamDecoder decoder) {
+            if (Objects.nonNull(decoder)) {
+                decoder.close();
+            }
+            if (!streamingUsageRecorded.get()) {
+                String sse = writer.output();
+                long usageTokens = extractUsageTokensFromSse(sse);
+                tokensRecorder.accept(usageTokens);
+            }
+        }
+
         private void processChunk(final byte[] processedBytes, final 
BodyWriter writer) {
             String chunk = new String(processedBytes, StandardCharsets.UTF_8);
             for (String line : SSE_LINE_PATTERN.split(chunk)) {
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/test/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterPluginTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/test/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterPluginTest.java
index 277427a091..994152d7e7 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/test/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterPluginTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/test/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterPluginTest.java
@@ -23,11 +23,23 @@ import 
org.apache.shenyu.infra.redis.serializer.ShenyuRedisSerializationContext;
 import 
org.apache.shenyu.plugin.ai.token.limiter.handler.AiTokenLimiterPluginHandler;
 import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.Mockito;
+import org.reactivestreams.Publisher;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferUtils;
 import org.springframework.data.redis.core.ReactiveRedisTemplate;
 import org.springframework.data.redis.core.ReactiveValueOperations;
 import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -35,8 +47,12 @@ import java.lang.reflect.Method;
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
 import java.util.zip.GZIPOutputStream;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -47,6 +63,53 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
  */
 class AiTokenLimiterPluginTest {
 
+    private static final String USAGE_EVENT = "data: 
{\"usage\":{\"completion_tokens\":12}}\n\n";
+
+    @ParameterizedTest
+    @CsvSource({"false, field", "true, field", "true, number"})
+    void testResponseUsageAcrossBuffers(final boolean flush, final String 
boundary) {
+        byte[] content = USAGE_EVENT.getBytes(StandardCharsets.UTF_8);
+        int split = USAGE_EVENT.indexOf("field".equals(boundary) ? "tokens" : 
"2");
+        byte[][] chunks = {Arrays.copyOfRange(content, 0, split), 
Arrays.copyOfRange(content, split, content.length)};
+        assertResponseUsage(content, chunks, flush, false);
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testResponseUsageAcrossGzipBuffers(final boolean flush) throws 
IOException {
+        byte[] compressed = compressGzip(USAGE_EVENT);
+        assertResponseUsage(compressed, splitIntoThreeChunks(compressed), 
flush, true);
+    }
+
+    @Test
+    void testFlushUsageIsFinalizedAfterOuterPublisherCompletes() {
+        MockServerWebExchange exchange = 
MockServerWebExchange.from(MockServerHttpRequest.get("/"));
+        ByteArrayOutputStream forwarded = new ByteArrayOutputStream();
+        exchange.getResponse().setWriteHandler(body -> body.doOnNext(buffer -> 
{
+            byte[] bytes = new byte[buffer.readableByteCount()];
+            buffer.read(bytes);
+            forwarded.writeBytes(bytes);
+            DataBufferUtils.release(buffer);
+        }).then());
+        List<Long> recorded = new ArrayList<>();
+        List<Throwable> errors = new ArrayList<>();
+        AiTokenLimiterPlugin.AiStatisticServerHttpResponse response = new 
AiTokenLimiterPlugin.AiStatisticServerHttpResponse(
+                exchange, exchange.getResponse(), recorded::add);
+        Sinks.Many<Publisher<? extends DataBuffer>> body = 
Sinks.many().unicast().onBackpressureBuffer();
+        response.writeAndFlushWith(body.asFlux()).subscribe(unused -> { }, 
errors::add);
+        int split = USAGE_EVENT.indexOf("tokens");
+        
body.tryEmitNext(Mono.just(exchange.getResponse().bufferFactory().wrap(USAGE_EVENT.substring(0,
 split).getBytes(StandardCharsets.UTF_8)))).orThrow();
+        assertEquals(USAGE_EVENT.substring(0, split), 
forwarded.toString(StandardCharsets.UTF_8));
+        assertTrue(recorded.isEmpty());
+        
body.tryEmitNext(Mono.just(exchange.getResponse().bufferFactory().wrap(USAGE_EVENT.substring(split).getBytes(StandardCharsets.UTF_8)))).orThrow();
+        assertEquals(USAGE_EVENT, forwarded.toString(StandardCharsets.UTF_8));
+        assertTrue(recorded.isEmpty());
+
+        body.tryEmitComplete().orThrow();
+        assertTrue(errors.isEmpty());
+        assertEquals(List.of(12L), recorded);
+    }
+
     @Test
     void testBodyWriterRetainsOnlyTailBytes() {
         AiTokenLimiterPlugin.BodyWriter writer = new 
AiTokenLimiterPlugin.BodyWriter(8);
@@ -242,6 +305,29 @@ class AiTokenLimiterPluginTest {
         assertEquals("0", valuePair.read(valuePair.write("0")));
     }
 
+    private void assertResponseUsage(final byte[] expected, final byte[][] 
chunks, final boolean flush, final boolean gzip) {
+        MockServerWebExchange exchange = 
MockServerWebExchange.from(MockServerHttpRequest.get("/"));
+        
exchange.getResponse().getHeaders().setContentType(MediaType.TEXT_EVENT_STREAM);
+        if (gzip) {
+            
exchange.getResponse().getHeaders().set(HttpHeaders.CONTENT_ENCODING, "gzip");
+        }
+        List<Long> recorded = new ArrayList<>();
+        AiTokenLimiterPlugin.AiStatisticServerHttpResponse response = new 
AiTokenLimiterPlugin.AiStatisticServerHttpResponse(
+                exchange, exchange.getResponse(), recorded::add);
+        Flux<DataBuffer> body = 
Flux.fromArray(chunks).map(exchange.getResponse().bufferFactory()::wrap);
+        Mono<Void> completion = flush ? 
response.writeAndFlushWith(body.map(Mono::just)) : response.writeWith(body);
+        completion.block();
+
+        assertEquals(List.of(12L), recorded);
+        byte[] forwarded = 
DataBufferUtils.join(exchange.getResponse().getBody()).map(buffer -> {
+            byte[] bytes = new byte[buffer.readableByteCount()];
+            buffer.read(bytes);
+            DataBufferUtils.release(buffer);
+            return bytes;
+        }).block();
+        assertArrayEquals(expected, forwarded);
+    }
+
     @SuppressWarnings("unchecked")
     private ReactiveValueOperations<String, String> mockValueOperations() {
         return Mockito.mock(ReactiveValueOperations.class);

Reply via email to