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 9344b5fbaf fix(ai): account Responses output_tokens in the AI token
limiter (#7937)
9344b5fbaf is described below
commit 9344b5fbafc929213b90b8d66e50febb2f5fdc14
Author: hanzhe-one <[email protected]>
AuthorDate: Fri Oct 23 11:21:58 2026 +0800
fix(ai): account Responses output_tokens in the AI token limiter (#7937)
The token limiter's response observer only recognized the Chat Completions
usage layout. ShenYu's enhanced AI proxy converts a Responses client's
traffic, and its converted bodies and terminal stream events report usage
as input_tokens/output_tokens, so a successful Responses completion was
forwarded intact but recorded as zero tokens.
Read the output tokens under either protocol: look for usage at the event
root (Chat) or inside the response object of a Responses event, accept
completion_tokens or output_tokens, and let the buffered fallback match
both names. The pattern keeps the leading quote so max_output_tokens, a
request echo, is never mistaken for a count. Accounting policy is
unchanged: the last reported output-token count is what is charged, and
the response bytes are still forwarded untouched.
Verified with the actual ConnectProtocolAdapter output: the new
integration test records 12 tokens for both the Chat and the converted
Responses body and stream, and fails on the previous code with
"expected: <12> but was: <0>".
Fixes #7911
---
.../apache/shenyu/common/constant/Constants.java | 5 +
.../shenyu-plugin-ai-token-limiter/pom.xml | 7 ++
.../ai/token/limiter/AiTokenLimiterPlugin.java | 57 +++++++--
.../ai/token/limiter/AiTokenLimiterPluginTest.java | 59 +++++++++-
.../limiter/AiTokenLimiterResponsesUsageTest.java | 127 +++++++++++++++++++++
5 files changed, 246 insertions(+), 9 deletions(-)
diff --git
a/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
b/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
index 82716875f4..f9c024fcd3 100644
---
a/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
+++
b/shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java
@@ -1056,6 +1056,11 @@ public interface Constants {
*/
String COMPLETION_TOKENS = "completion_tokens";
+ /**
+ * The output tokens reported by the OpenAI Responses protocol, the
counterpart of COMPLETION_TOKENS.
+ */
+ String OUTPUT_TOKENS = "output_tokens";
+
/**
* The constant AI_MODEL.
*/
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/pom.xml
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/pom.xml
index dd1bfe3d34..e1a96254e7 100644
--- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/pom.xml
+++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/pom.xml
@@ -32,6 +32,13 @@
<artifactId>shenyu-infra-redis</artifactId>
<version>${project.version}</version>
</dependency>
+ <!-- The observer is covered against the bytes the AI proxy actually
produces. -->
+ <dependency>
+ <groupId>org.apache.shenyu</groupId>
+ <artifactId>shenyu-plugin-ai-proxy</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
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 3631130958..f12eeefc57 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
@@ -71,7 +71,17 @@ public class AiTokenLimiterPlugin extends
AbstractShenyuPlugin {
private static final String REDIS_KEY_PREFIX = "SHENYU:AI:TOKENLIMIT:";
- private static final Pattern COMPLETION_TOKENS_PATTERN =
Pattern.compile("\"completion_tokens\"\\s*:\\s*(\\d+)");
+ /**
+ * The envelope of an OpenAI Responses terminal event, which carries the
usage of a response. Kept local because a
+ * bare {@code response} name in the shared {@link Constants} would read
as an HTTP response.
+ */
+ private static final String RESPONSES_EVENT_OBJECT = "response";
+
+ /**
+ * Output tokens of the Chat and Responses protocols. The leading quote
keeps {@code max_output_tokens}, a request
+ * echo, from being read as an accounted count.
+ */
+ private static final Pattern OUTPUT_TOKENS_PATTERN =
Pattern.compile("\"(?:completion|output)_tokens\"\\s*:\\s*(\\d+)");
private static final Pattern SSE_LINE_PATTERN = Pattern.compile("\\r?\\n");
@@ -294,10 +304,10 @@ public class AiTokenLimiterPlugin extends
AbstractShenyuPlugin {
}
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);
+ JsonNode usage = usageOf(node);
+ JsonNode outputTokens = Objects.isNull(usage) ? null :
outputTokensOf(usage);
+ if (Objects.nonNull(outputTokens)) {
+ tokensRecorder.accept(outputTokens.asLong());
streamingUsageRecorded.set(true);
}
} catch (Exception e) {
@@ -307,11 +317,42 @@ public class AiTokenLimiterPlugin extends
AbstractShenyuPlugin {
writer.write(ByteBuffer.wrap(processedBytes));
}
+ /**
+ * Locate the usage object of a payload: Chat payloads carry it at the
root, while a Responses terminal event
+ * carries it inside its {@code response} object.
+ *
+ * @param node parsed payload of a stream event or a complete body
+ * @return the usage object, or null when the payload reports none
+ */
+ private JsonNode usageOf(final JsonNode node) {
+ JsonNode usage = node.get(Constants.USAGE);
+ if (Objects.isNull(usage)) {
+ // Chat never uses a top-level "response" field, so the
fallback cannot shadow a Chat payload.
+ JsonNode responsesEvent = node.get(RESPONSES_EVENT_OBJECT);
+ usage = Objects.isNull(responsesEvent) ? null :
responsesEvent.get(Constants.USAGE);
+ }
+ return Objects.nonNull(usage) && usage.isObject() ? usage : null;
+ }
+
+ /**
+ * Read the output tokens of a usage object under either protocol's
field name.
+ *
+ * @param usage usage object of a Chat or Responses payload
+ * @return the reported output tokens, or null when the object has
neither field
+ */
+ private JsonNode outputTokensOf(final JsonNode usage) {
+ if (usage.has(Constants.COMPLETION_TOKENS)) {
+ return usage.get(Constants.COMPLETION_TOKENS);
+ }
+ return usage.has(Constants.OUTPUT_TOKENS) ?
usage.get(Constants.OUTPUT_TOKENS) : null;
+ }
+
private long extractUsageTokensFromSse(final String sse) {
- Matcher m = COMPLETION_TOKENS_PATTERN.matcher(sse);
+ // The usage of a body is reported once, at its end, so the last
match of either field is the count.
+ Matcher matcher = OUTPUT_TOKENS_PATTERN.matcher(sse);
long last = 0L;
- while (m.find()) {
- last = Long.parseLong(m.group(1));
+ while (matcher.find()) {
+ last = Long.parseLong(matcher.group(1));
}
return last;
}
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 994152d7e7..1477e58dc1 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
@@ -65,6 +65,53 @@ class AiTokenLimiterPluginTest {
private static final String USAGE_EVENT = "data:
{\"usage\":{\"completion_tokens\":12}}\n\n";
+ private static final String RESPONSES_COMPLETED_EVENT = "event:
response.completed\n"
+ + "data:
{\"type\":\"response.completed\",\"sequence_number\":4,\"response\":{\"id\":\"resp_1\",\"object\":\"response\","
+ +
"\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":12,\"total_tokens\":15}}}\n\n";
+
+ private static final String RESPONSES_BODY =
"{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\","
+ +
"\"output\":[],\"usage\":{\"input_tokens\":3,\"output_tokens\":12,\"total_tokens\":15}}";
+
+ @Test
+ void testResponsesStreamEventRecordsOutputTokens() {
+ // Issue #7911: a Responses terminal event carries its usage inside
the response object.
+ assertResponseUsage(RESPONSES_COMPLETED_EVENT, new byte[][]
{RESPONSES_COMPLETED_EVENT.getBytes(StandardCharsets.UTF_8)},
+ false, false, 12L);
+ }
+
+ @Test
+ void testResponsesStreamEventAcrossBuffersRecordsOutputTokens() {
+ // Split inside the output_tokens key: neither buffer holds a complete
event, so the streaming JSON-parse path
+ // finds nothing and the regex fallback over the accumulated SSE tail
is what accounts the usage.
+ byte[] content =
RESPONSES_COMPLETED_EVENT.getBytes(StandardCharsets.UTF_8);
+ int split = RESPONSES_COMPLETED_EVENT.indexOf("\"output_tokens\"");
+ byte[][] chunks = {Arrays.copyOfRange(content, 0, split),
Arrays.copyOfRange(content, split, content.length)};
+ for (boolean flush : new boolean[] {false, true}) {
+ assertResponseUsage(RESPONSES_COMPLETED_EVENT, chunks, flush,
false, 12L);
+ }
+ }
+
+ @Test
+ void testCompleteResponsesBodyRecordsOutputTokens() {
+ // A converted, non-streaming Responses body is not SSE framed: only
the buffered fallback sees it.
+ byte[] content = RESPONSES_BODY.getBytes(StandardCharsets.UTF_8);
+ assertResponseUsage(RESPONSES_BODY, new byte[][] {content}, false,
false, 12L);
+ }
+
+ @Test
+ void testResponsesUsageIsNotReadFromMaxOutputTokens() {
+ // max_output_tokens is an echo of the request limit, never an
accounted count.
+ String body =
"{\"id\":\"resp_1\",\"object\":\"response\",\"max_output_tokens\":99,\"usage\":{\"input_tokens\":3,"
+ + "\"output_tokens\":12,\"total_tokens\":15}}";
+ assertResponseUsage(body, new byte[][]
{body.getBytes(StandardCharsets.UTF_8)}, false, false, 12L);
+ }
+
+ @Test
+ void testResponsesUsageWithoutOutputTokensRecordsNothing() {
+ String body =
"{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"incomplete\",\"max_output_tokens\":99}";
+ assertResponseUsage(body, new byte[][]
{body.getBytes(StandardCharsets.UTF_8)}, false, false, 0L);
+ }
+
@ParameterizedTest
@CsvSource({"false, field", "true, field", "true, number"})
void testResponseUsageAcrossBuffers(final boolean flush, final String
boundary) {
@@ -306,6 +353,16 @@ class AiTokenLimiterPluginTest {
}
private void assertResponseUsage(final byte[] expected, final byte[][]
chunks, final boolean flush, final boolean gzip) {
+ assertResponseUsage(expected, chunks, flush, gzip, 12L);
+ }
+
+ private void assertResponseUsage(final String expected, final byte[][]
chunks, final boolean flush, final boolean gzip,
+ final long expectedTokens) {
+ assertResponseUsage(expected.getBytes(StandardCharsets.UTF_8), chunks,
flush, gzip, expectedTokens);
+ }
+
+ private void assertResponseUsage(final byte[] expected, final byte[][]
chunks, final boolean flush, final boolean gzip,
+ final long expectedTokens) {
MockServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.get("/"));
exchange.getResponse().getHeaders().setContentType(MediaType.TEXT_EVENT_STREAM);
if (gzip) {
@@ -318,7 +375,7 @@ class AiTokenLimiterPluginTest {
Mono<Void> completion = flush ?
response.writeAndFlushWith(body.map(Mono::just)) : response.writeWith(body);
completion.block();
- assertEquals(List.of(12L), recorded);
+ assertEquals(List.of(expectedTokens), recorded);
byte[] forwarded =
DataBufferUtils.join(exchange.getResponse().getBody()).map(buffer -> {
byte[] bytes = new byte[buffer.readableByteCount()];
buffer.read(bytes);
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/test/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterResponsesUsageTest.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/test/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterResponsesUsageTest.java
new file mode 100644
index 0000000000..4e35e59a57
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/test/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterResponsesUsageTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamResponse;
+import
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.ConnectProtocolAdapter;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferUtils;
+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 java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Usage accounting on the exact bytes ShenYu's AI proxy converts to, for both
client protocols.
+ */
+class AiTokenLimiterResponsesUsageTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ private static final String CHAT_COMPLETION =
"{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion\",\"model\":\"model\","
+ +
"\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"hello\"},\"finish_reason\":\"stop\"}],"
+ +
"\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":12,\"total_tokens\":15}}";
+
+ private static final String CHAT_STREAM = "data:
{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"model\":\"model\","
+ +
"\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hello\"},\"finish_reason\":\"stop\"}]}\n\n"
+ + "data:
{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"model\":\"model\",\"choices\":[],"
+ +
"\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":12,\"total_tokens\":15}}\n\n"
+ + "data: [DONE]\n\n";
+
+ @Test
+ void recordsTheSameUsageForChatAndConvertedResponsesBodies() {
+ // Issue #7911: the Responses body is produced by the real adapter,
not a hand-written fixture.
+ byte[] responses = converted(false);
+ assertEquals(12L, recordedUsage(false,
CHAT_COMPLETION.getBytes(StandardCharsets.UTF_8)));
+ assertEquals(12L, recordedUsage(false, responses));
+ }
+
+ @Test
+ void recordsTheSameUsageForChatAndConvertedResponsesStreams() {
+ byte[] responses = converted(true);
+ assertEquals(12L, recordedUsage(true,
CHAT_STREAM.getBytes(StandardCharsets.UTF_8)));
+ assertEquals(12L, recordedUsage(true, responses));
+ }
+
+ @Test
+ void forwardsTheConvertedBytesUnchanged() {
+ for (boolean stream : new boolean[] {false, true}) {
+ byte[] responses = converted(stream);
+ assertArrayEquals(responses, forwarded(stream, responses));
+ }
+ }
+
+ private byte[] converted(final boolean stream) {
+ ConnectProtocolAdapter adapter = new
ConnectProtocolAdapter(OpenAiResponses.NAME, null, null);
+ AiUpstreamResponse upstream = new AiUpstreamResponse(200,
+ Map.of("Content-Type", List.of(stream ?
MediaType.TEXT_EVENT_STREAM_VALUE : MediaType.APPLICATION_JSON_VALUE)),
+ Flux.just((stream ? CHAT_STREAM :
CHAT_COMPLETION).getBytes(StandardCharsets.UTF_8)));
+ return adapter.convertResponse(upstream, stream).getBody()
+ .reduce(new ByteArrayOutputStream(), (buffer, bytes) -> {
+ buffer.writeBytes(bytes);
+ return buffer;
+ })
+ .map(ByteArrayOutputStream::toByteArray)
+ .block(TIMEOUT);
+ }
+
+ private long recordedUsage(final boolean stream, final byte[] body) {
+ List<Long> recorded = new ArrayList<>();
+ MockServerWebExchange exchange = exchange(stream);
+ AiTokenLimiterPlugin.AiStatisticServerHttpResponse observer = new
AiTokenLimiterPlugin.AiStatisticServerHttpResponse(
+ exchange, exchange.getResponse(), recorded::add);
+ observer.writeWith(Flux.just(buffer(exchange, body))).block(TIMEOUT);
+ assertEquals(1, recorded.size());
+ return recorded.get(0);
+ }
+
+ private byte[] forwarded(final boolean stream, final byte[] body) {
+ MockServerWebExchange exchange = exchange(stream);
+ AiTokenLimiterPlugin.AiStatisticServerHttpResponse observer = new
AiTokenLimiterPlugin.AiStatisticServerHttpResponse(
+ exchange, exchange.getResponse(), tokens -> { });
+ observer.writeWith(Flux.just(buffer(exchange, body))).block(TIMEOUT);
+ return
DataBufferUtils.join(exchange.getResponse().getBody()).map(buffer -> {
+ byte[] bytes = new byte[buffer.readableByteCount()];
+ buffer.read(bytes);
+ DataBufferUtils.release(buffer);
+ return bytes;
+ }).block(TIMEOUT);
+ }
+
+ private MockServerWebExchange exchange(final boolean stream) {
+ MockServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.post("/v1/responses"));
+ exchange.getResponse().getHeaders().setContentType(stream ?
MediaType.TEXT_EVENT_STREAM : MediaType.APPLICATION_JSON);
+ return exchange;
+ }
+
+ private DataBuffer buffer(final MockServerWebExchange exchange, final
byte[] body) {
+ return exchange.getResponse().bufferFactory().wrap(body);
+ }
+}