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 82c9d544c8 fix: AI token limiter null handling, rule isolation, and
gzip streaming (#6513, #6514, #6515) (#7138)
82c9d544c8 is described below
commit 82c9d544c82948e6a2f0769092c9e8bcd3146b1b
Author: HY-love-sleep <[email protected]>
AuthorDate: Mon Sep 21 10:03:40 2026 +0800
fix: AI token limiter null handling, rule isolation, and gzip streaming
(#6513, #6514, #6515) (#7138)
---
.../ai/token/limiter/AiTokenLimiterPlugin.java | 119 ++++---------
.../plugin/ai/token/limiter/GzipStreamDecoder.java | 191 +++++++++++++++++++++
.../handler/AiTokenLimiterPluginHandler.java | 14 ++
.../ai/token/limiter/AiTokenLimiterPluginTest.java | 144 ++++++++++++++++
4 files changed, 385 insertions(+), 83 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 74ae710783..0094a14313 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
@@ -51,7 +51,6 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.annotation.NonNull;
-import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@@ -61,8 +60,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import java.util.zip.DataFormatException;
-import java.util.zip.Inflater;
/**
* Shenyu ai token limiter plugin.
@@ -88,13 +85,13 @@ public class AiTokenLimiterPlugin extends
AbstractShenyuPlugin {
ReactiveRedisTemplate reactiveRedisTemplate =
AiTokenLimiterPluginHandler.REDIS_CACHED_HANDLE.get().obtainHandle(PluginEnum.AI_TOKEN_LIMITER.getName());
Assert.notNull(reactiveRedisTemplate, "reactiveRedisTemplate is null");
- // generate redis key
+ // generate redis key - include rule id to scope counters per rule
String tokenLimitType = aiTokenLimiterHandle.getAiTokenLimitType();
String keyName = aiTokenLimiterHandle.getKeyName();
Long tokenLimit = aiTokenLimiterHandle.getTokenLimit();
Long timeWindowSeconds = aiTokenLimiterHandle.getTimeWindowSeconds();
- String cacheKey = REDIS_KEY_PREFIX + getCacheKey(exchange,
tokenLimitType, keyName);
+ String cacheKey = REDIS_KEY_PREFIX + CacheKeyUtils.INST.getKey(rule) +
":" + getCacheKey(exchange, tokenLimitType, keyName);
final AiStatisticServerHttpResponse loggingServerHttpResponse = new
AiStatisticServerHttpResponse(exchange, exchange.getResponse(),
tokens -> recordTokensUsage(reactiveRedisTemplate,
@@ -229,9 +226,7 @@ public class AiTokenLimiterPlugin extends
AbstractShenyuPlugin {
&& headers.getFirst(Constants.CONTENT_ENCODING)
.contains(Constants.HTTP_ACCEPT_ENCODING_GZIP);
- final Inflater inflater = isGzip ? new Inflater(true) : null;
- final byte[] outBuf = new byte[4096];
- final AtomicBoolean headerSkipped = new AtomicBoolean(!isGzip);
+ final GzipStreamDecoder decoder = isGzip ? new GzipStreamDecoder()
: null;
return Flux.<DataBuffer>from(body)
.doOnNext(buffer -> {
@@ -241,59 +236,18 @@ public class AiTokenLimiterPlugin extends
AbstractShenyuPlugin {
byte[] inBytes = new byte[ro.remaining()];
ro.get(inBytes);
- byte[] processedBytes;
- if (isGzip) {
- int offset = 0;
- if (headerSkipped.compareAndSet(false,
true)) {
- offset = skipGzipHeader(inBytes);
- }
- inflater.setInput(inBytes, offset,
inBytes.length - offset);
- ByteArrayOutputStream baos = new
ByteArrayOutputStream();
- try {
- int cnt;
- while ((cnt =
inflater.inflate(outBuf)) > 0) {
- baos.write(outBuf, 0, cnt);
- }
- } catch (DataFormatException ex) {
- LOG.error("Inflater decompression
failed", ex);
- }
- processedBytes = baos.toByteArray();
- } else {
- processedBytes = inBytes;
+ byte[] processedBytes = isGzip ?
decoder.decode(inBytes) : inBytes;
+ if (processedBytes.length > 0) {
+ processChunk(processedBytes, writer);
}
- String chunk = new String(processedBytes,
StandardCharsets.UTF_8);
- for (String line : chunk.split("\\r?\\n")) {
- if (!line.startsWith("data:")) {
- continue;
- }
- String payload =
line.substring("data:".length()).trim();
- if (payload.isEmpty() ||
"[DONE]".equals(payload)) {
- continue;
- }
- if (!payload.startsWith("{")) {
- continue;
- }
- try {
- JsonNode node =
MAPPER.readTree(payload);
- JsonNode usage =
node.get(Constants.USAGE);
- if (Objects.nonNull(usage) &&
usage.has(Constants.COMPLETION_TOKENS)) {
- long c =
usage.get(Constants.COMPLETION_TOKENS).asLong();
- tokensRecorder.accept(c);
- streamingUsageRecorded.set(true);
- }
- } catch (Exception e) {
- LOG.error("Failed to parse AI response
JSON payload", e);
- }
- }
- writer.write(ByteBuffer.wrap(processedBytes));
});
} catch (Exception e) {
LOG.error("read dataBuffer error", e);
}
})
.doFinally(signal -> {
- if (Objects.nonNull(inflater)) {
- inflater.end();
+ if (Objects.nonNull(decoder)) {
+ decoder.close();
}
if (!streamingUsageRecorded.get()) {
String sse = writer.output();
@@ -303,6 +257,34 @@ public class AiTokenLimiterPlugin extends
AbstractShenyuPlugin {
});
}
+ private void processChunk(final byte[] processedBytes, final
BodyWriter writer) {
+ String chunk = new String(processedBytes, StandardCharsets.UTF_8);
+ for (String line : chunk.split("\\r?\\n")) {
+ if (!line.startsWith("data:")) {
+ continue;
+ }
+ String payload = line.substring("data:".length()).trim();
+ if (payload.isEmpty() || "[DONE]".equals(payload)) {
+ continue;
+ }
+ if (!payload.startsWith("{")) {
+ continue;
+ }
+ try {
+ JsonNode node = MAPPER.readTree(payload);
+ JsonNode usage = node.get(Constants.USAGE);
+ if (Objects.nonNull(usage) &&
usage.has(Constants.COMPLETION_TOKENS)) {
+ long c =
usage.get(Constants.COMPLETION_TOKENS).asLong();
+ tokensRecorder.accept(c);
+ streamingUsageRecorded.set(true);
+ }
+ } catch (Exception e) {
+ LOG.error("Failed to parse AI response JSON payload", e);
+ }
+ }
+ writer.write(ByteBuffer.wrap(processedBytes));
+ }
+
private long extractUsageTokensFromSse(final String sse) {
Matcher m = COMPLETION_TOKENS_PATTERN.matcher(sse);
long last = 0L;
@@ -312,35 +294,6 @@ public class AiTokenLimiterPlugin extends
AbstractShenyuPlugin {
return last;
}
- private int skipGzipHeader(final byte[] b) {
- int pos = 10;
- int flg = b[3] & 0xFF;
-
- if ((flg & 0x04) != 0) {
- int xlen = (b[pos] & 0xFF) | ((b[pos + 1] & 0xFF) << 8);
- pos += 2 + xlen;
- }
-
- if ((flg & 0x08) != 0) {
- while (b[pos] != 0) {
- pos++;
- }
- pos++;
- }
-
- if ((flg & 0x10) != 0) {
- while (b[pos] != 0) {
- pos++;
- }
- pos++;
- }
-
- if ((flg & 0x02) != 0) {
- pos += 2;
- }
- return pos;
- }
-
}
static class BodyWriter {
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/GzipStreamDecoder.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/GzipStreamDecoder.java
new file mode 100644
index 0000000000..b617d24426
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/GzipStreamDecoder.java
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.token.limiter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayOutputStream;
+import java.util.zip.DataFormatException;
+import java.util.zip.Inflater;
+
+/**
+ * Streaming gzip decoder for handling cross-buffer gzip decompression.
+ * Package-visible for testing.
+ */
+class GzipStreamDecoder {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(GzipStreamDecoder.class);
+
+ private final Inflater inflater = new Inflater(true);
+
+ private final byte[] decompressBuffer = new byte[4096];
+
+ private final GzipHeaderState headerState = new GzipHeaderState();
+
+ private boolean abandoned;
+
+ /**
+ * Decode a chunk of gzip data.
+ * @param inBytes compressed input bytes
+ * @return decompressed bytes, or empty array if header incomplete or
abandoned
+ */
+ byte[] decode(final byte[] inBytes) {
+ if (abandoned) {
+ return new byte[0];
+ }
+
+ int offset = 0;
+ if (!headerState.isComplete()) {
+ offset = headerState.process(inBytes);
+ if (headerState.isCapacityExceeded()) {
+ abandoned = true;
+ return new byte[0];
+ }
+ if (!headerState.isComplete()) {
+ return new byte[0];
+ }
+ }
+
+ inflater.setInput(inBytes, offset, inBytes.length - offset);
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try {
+ int cnt;
+ while (!inflater.needsInput() && (cnt =
inflater.inflate(decompressBuffer)) > 0) {
+ baos.write(decompressBuffer, 0, cnt);
+ }
+ } catch (DataFormatException ex) {
+ LOG.error("Inflater decompression failed", ex);
+ abandoned = true;
+ return new byte[0];
+ }
+ return baos.toByteArray();
+ }
+
+ void close() {
+ inflater.end();
+ }
+
+ /**
+ * Track gzip header parsing state across buffers.
+ */
+ static class GzipHeaderState {
+
+ private static final int MAX_HEADER_SIZE = 10 + 256;
+
+ private final byte[] accumulatedHeader = new byte[MAX_HEADER_SIZE];
+
+ private int accumulatedLength;
+
+ private boolean complete;
+
+ private boolean capacityExceeded;
+
+ boolean isComplete() {
+ return complete;
+ }
+
+ boolean isCapacityExceeded() {
+ return capacityExceeded;
+ }
+
+ /**
+ * Process gzip header bytes, potentially spanning multiple buffers.
+ *
+ * @param inBytes input bytes
+ * @return offset where compressed data starts (0 if header is still
incomplete)
+ */
+ int process(final byte[] inBytes) {
+ if (complete || capacityExceeded) {
+ return 0;
+ }
+
+ final int prev = accumulatedLength;
+ final int toCopy = Math.min(inBytes.length,
accumulatedHeader.length - prev);
+ System.arraycopy(inBytes, 0, accumulatedHeader, prev, toCopy);
+ accumulatedLength += toCopy;
+
+ if (accumulatedLength < 10) {
+ return 0;
+ }
+
+ try {
+ int pos = 10;
+ int flg = accumulatedHeader[3] & 0xFF;
+
+ if ((flg & 0x04) != 0) {
+ if (accumulatedLength < pos + 2) {
+ return headerIncomplete();
+ }
+ int xlen = (accumulatedHeader[pos] & 0xFF) |
((accumulatedHeader[pos + 1] & 0xFF) << 8);
+ pos += 2 + xlen;
+ if (accumulatedLength < pos) {
+ return headerIncomplete();
+ }
+ }
+
+ if ((flg & 0x08) != 0) {
+ while (pos < accumulatedLength && accumulatedHeader[pos]
!= 0) {
+ pos++;
+ }
+ if (pos >= accumulatedLength) {
+ return headerIncomplete();
+ }
+ pos++;
+ }
+
+ if ((flg & 0x10) != 0) {
+ while (pos < accumulatedLength && accumulatedHeader[pos]
!= 0) {
+ pos++;
+ }
+ if (pos >= accumulatedLength) {
+ return headerIncomplete();
+ }
+ pos++;
+ }
+
+ if ((flg & 0x02) != 0) {
+ if (accumulatedLength < pos + 2) {
+ return headerIncomplete();
+ }
+ pos += 2;
+ }
+
+ complete = true;
+ return pos - prev;
+
+ } catch (ArrayIndexOutOfBoundsException e) {
+ // Defensive: the bounds checks above should make this
unreachable.
+ // Abandon decompression instead of letting the error
propagate into the
+ // reactive pipeline, where it would abort the response for
the client.
+ capacityExceeded = true;
+ LOG.warn("Unexpected gzip header parse error, decompression
abandoned", e);
+ return 0;
+ }
+ }
+
+ private int headerIncomplete() {
+ if (accumulatedLength >= accumulatedHeader.length) {
+ capacityExceeded = true;
+ LOG.warn("Gzip header exceeds maximum size of {} bytes,
decompression abandoned. "
+ + "This may occur with long FNAME or FCOMMENT
fields.", MAX_HEADER_SIZE);
+ }
+ return 0;
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/handler/AiTokenLimiterPluginHandler.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/handler/AiTokenLimiterPluginHandler.java
index 5408872e3b..b97738471e 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/handler/AiTokenLimiterPluginHandler.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/handler/AiTokenLimiterPluginHandler.java
@@ -88,6 +88,20 @@ public class AiTokenLimiterPluginHandler implements
PluginDataHandler {
public void handlerRule(final RuleData ruleData) {
Optional.ofNullable(ruleData.getHandle()).ifPresent(s -> {
final AiTokenLimiterHandle rateLimiterHandle =
GsonUtils.getInstance().fromJson(s, AiTokenLimiterHandle.class);
+ // Fill defaults for null fields to prevent NPE
+ AiTokenLimiterHandle defaultHandle =
AiTokenLimiterHandle.newDefaultInstance();
+ if (Objects.isNull(rateLimiterHandle.getTokenLimit())) {
+ rateLimiterHandle.setTokenLimit(defaultHandle.getTokenLimit());
+ }
+ if (Objects.isNull(rateLimiterHandle.getTimeWindowSeconds())) {
+
rateLimiterHandle.setTimeWindowSeconds(defaultHandle.getTimeWindowSeconds());
+ }
+ if (Objects.isNull(rateLimiterHandle.getAiTokenLimitType())) {
+
rateLimiterHandle.setAiTokenLimitType(defaultHandle.getAiTokenLimitType());
+ }
+ if (Objects.isNull(rateLimiterHandle.getKeyName())) {
+ rateLimiterHandle.setKeyName(defaultHandle.getKeyName());
+ }
CACHED_HANDLE.get().cachedHandle(CacheKeyUtils.INST.getKey(ruleData),
rateLimiterHandle);
});
}
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 d929206d80..3142a60497 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
@@ -17,13 +17,21 @@
package org.apache.shenyu.plugin.ai.token.limiter;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.convert.rule.AiTokenLimiterHandle;
+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 java.io.ByteArrayOutputStream;
+import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
+import java.util.zip.GZIPOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -51,4 +59,140 @@ class AiTokenLimiterPluginTest {
assertEquals("23456789", writer.output());
}
+
+ @Test
+ void testHandlerRuleWithNullFieldsFillsDefaults() {
+ // Test for issue #6513: null fields should be filled with defaults
+ AiTokenLimiterPluginHandler handler = new
AiTokenLimiterPluginHandler();
+
+ RuleData ruleData = RuleData.builder()
+ .id("test-rule-id")
+ .selectorId("test-selector-id")
+ .name("test-rule")
+
.handle("{\"aiTokenLimitType\":\"uri\",\"keyName\":\"default\"}")
+ .build();
+
+ handler.handlerRule(ruleData);
+
+ AiTokenLimiterHandle cached =
AiTokenLimiterPluginHandler.CACHED_HANDLE.get()
+ .obtainHandle(CacheKeyUtils.INST.getKey(ruleData));
+
+ assertNotNull(cached);
+ assertEquals("uri", cached.getAiTokenLimitType());
+ assertEquals("default", cached.getKeyName());
+ // These should be filled with defaults
+ assertNotNull(cached.getTokenLimit());
+ assertNotNull(cached.getTimeWindowSeconds());
+ assertEquals(Long.valueOf(100L), cached.getTokenLimit());
+ assertEquals(Long.valueOf(60L), cached.getTimeWindowSeconds());
+ }
+
+ @Test
+ void testGzipDecoderWithHeaderSpanningBuffers() throws IOException {
+ // Test for issue #6515: gzip header spanning multiple buffers
+ String sseContent = "data:
{\"id\":\"test\",\"usage\":{\"completion_tokens\":50}}\n\n";
+ byte[] compressed = compressGzip(sseContent);
+
+ // Split at byte 8 - header boundary
+ byte[] chunk1 = new byte[8];
+ byte[] chunk2 = new byte[compressed.length - 8];
+ System.arraycopy(compressed, 0, chunk1, 0, 8);
+ System.arraycopy(compressed, 8, chunk2, 0, chunk2.length);
+
+ GzipStreamDecoder decoder = new GzipStreamDecoder();
+ byte[] result1 = decoder.decode(chunk1);
+ // Header incomplete
+ assertEquals(0, result1.length);
+
+ byte[] result2 = decoder.decode(chunk2);
+ // Should decompress now
+ assertTrue(result2.length > 0);
+ assertEquals(sseContent, new String(result2, StandardCharsets.UTF_8));
+ decoder.close();
+ }
+
+ @Test
+ void testGzipDecoderWithCompleteHeaderInFirstBuffer() throws IOException {
+ String sseContent = "data:
{\"id\":\"test\",\"usage\":{\"completion_tokens\":50}}\n\n";
+ byte[] compressed = compressGzip(sseContent);
+
+ GzipStreamDecoder decoder = new GzipStreamDecoder();
+ byte[] result = decoder.decode(compressed);
+ assertEquals(sseContent, new String(result, StandardCharsets.UTF_8));
+ decoder.close();
+ }
+
+ @Test
+ void testGzipDecoderWithLargeFirstChunk() throws IOException {
+ // Regression: first chunk far larger than header buffer limit (266)
+ // historically would incorrectly abandon entire response
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 200; i++) {
+ sb.append("data: {\"id\":\"chatcmpl-").append(i)
+
.append("\",\"choices\":[{\"delta\":{\"content\":\"token-").append(i *
7919).append("\"}}]}\n\n");
+ }
+ sb.append("data: {\"usage\":{\"completion_tokens\":75}}\n\n");
+ String sseContent = sb.toString();
+
+ byte[] compressed = compressGzip(sseContent);
+ assertTrue(compressed.length > 266, "Compressed data must exceed
header buffer size");
+
+ GzipStreamDecoder decoder = new GzipStreamDecoder();
+ byte[] result = decoder.decode(compressed);
+ decoder.close();
+
+ assertEquals(sseContent, new String(result, StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void testEndToEndGzipDecompressionAcrossMultipleChunks() throws
IOException {
+ // End-to-end test: verify full decompression pipeline with 3 chunks
+ String sseContent = buildSseContentWithTokens(75);
+ byte[] compressed = compressGzip(sseContent);
+ byte[][] chunks = splitIntoThreeChunks(compressed);
+
+ GzipStreamDecoder decoder = new GzipStreamDecoder();
+ StringBuilder decompressed = new StringBuilder();
+
+ for (byte[] chunk : chunks) {
+ byte[] result = decoder.decode(chunk);
+ if (result.length > 0) {
+ decompressed.append(new String(result,
StandardCharsets.UTF_8));
+ }
+ }
+ decoder.close();
+
+ // Verify decompression succeeded
+ assertEquals(sseContent, decompressed.toString());
+ assertTrue(decompressed.toString().contains("completion_tokens\":75"));
+ }
+
+ private String buildSseContentWithTokens(final int tokens) {
+ return "data:
{\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n"
+ + "data:
{\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{\"content\":\" World\"}}]}\n\n"
+ + "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"delta\":{}}],"
+ + "\"usage\":{\"completion_tokens\":" + tokens +
",\"prompt_tokens\":10,\"total_tokens\":" + (tokens + 10) + "}}\n\n"
+ + "data: [DONE]\n\n";
+ }
+
+ private byte[] compressGzip(final String content) throws IOException {
+ ByteArrayOutputStream compressedStream = new ByteArrayOutputStream();
+ try (GZIPOutputStream gzipOutputStream = new
GZIPOutputStream(compressedStream)) {
+ gzipOutputStream.write(content.getBytes(StandardCharsets.UTF_8));
+ }
+ return compressedStream.toByteArray();
+ }
+
+ private byte[][] splitIntoThreeChunks(final byte[] data) {
+ byte[] chunk1 = new byte[8];
+ int chunk2Size = (data.length - 8) / 2;
+ byte[] chunk2 = new byte[chunk2Size];
+ byte[] chunk3 = new byte[data.length - 8 - chunk2Size];
+
+ System.arraycopy(data, 0, chunk1, 0, 8);
+ System.arraycopy(data, 8, chunk2, 0, chunk2Size);
+ System.arraycopy(data, 8 + chunk2Size, chunk3, 0, chunk3.length);
+
+ return new byte[][]{chunk1, chunk2, chunk3};
+ }
}