utafrali commented on code in PR #7937:
URL: https://github.com/apache/shenyu/pull/7937#discussion_r4334419706


##########
shenyu-common/src/main/java/org/apache/shenyu/common/constant/Constants.java:
##########
@@ -1056,6 +1056,16 @@ 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 response object of an OpenAI Responses stream event, which carries 
its usage.

Review Comment:
   The constant name `RESPONSE = "response"` is too generic for a shared 
constants interface that already contains `RESPONSE_MONO`, 
`CLIENT_RESPONSE_ATTR`, `CLIENT_RESPONSE_CONN_ATTR`, and 
`ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR`. A reader seeing `Constants.RESPONSE` at 
a call site has no indication it refers to the `response` envelope object of an 
OpenAI Responses stream event.
   
   Consider a more specific name such as `RESPONSES_RESPONSE_OBJECT` or 
`AI_RESPONSES_RESPONSE`, mirroring the pattern used for other AI-specific 
constants (`USAGE`, `COMPLETION_TOKENS`). Alternatively, define it as a 
package-private constant inside the `AiTokenLimiterPlugin` itself, since it is 
only referenced there.



##########
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"

Review Comment:
   The test drives the observer with bytes produced by 
`ConnectProtocolAdapter`, which means a change to the adapter's output format 
(field order, whitespace, added fields) will silently break this test even when 
the token-limiter logic itself is unchanged. The class-level Javadoc notes the 
intent, but it is worth adding an inline comment here flagging that this is the 
integration point:
   
   ```java
   // If this assertion breaks after adapter changes, update CHAT_COMPLETION / 
CHAT_STREAM
   // to match the new adapter output before debugging the token-limiter logic.
   byte[] responses = converted(false);
   ```
   This makes triage faster for contributors who see the failure without 
reading the PR description.



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-token-limiter/src/main/java/org/apache/shenyu/plugin/ai/token/limiter/AiTokenLimiterPlugin.java:
##########
@@ -307,11 +311,38 @@ private void processChunk(final byte[] processedBytes, 
final BodyWriter writer)
             writer.write(ByteBuffer.wrap(processedBytes));
         }
 
+        /**
+         * Locate the usage object of a stream event: Chat events carry it at 
the root, while Responses events
+         * carry it inside the {@code response} object of the terminal event.
+         */
+        private JsonNode usageOf(final JsonNode node) {
+            JsonNode usage = node.get(Constants.USAGE);
+            if (Objects.isNull(usage)) {

Review Comment:
   The fallback path in `usageOf()` checks for a top-level `response` field 
only when `usage` is absent at the root. The assumption is that a Chat 
Completions event never has a top-level `response` object — which is true 
today, but it is an implicit invariant. A brief comment would make the guard's 
reasoning explicit and protect a future reader:
   
   ```java
   // Chat events put usage at the root; Responses terminal events wrap it 
inside response.usage.
   // Chat Completions never uses a top-level "response" field, so the fallback 
is unambiguous.
   if (Objects.isNull(usage)) {
   ```



##########
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() {
+        // The observer parses each buffer on its own, so the whole event has 
to arrive in one buffer; the fallback
+        // then accounts the buffered tail. Both paths must agree on the 
Responses field.
+        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);

Review Comment:
   The comment "the fallback then accounts the buffered tail" in 
`testResponsesStreamEventAcrossBuffersRecordsOutputTokens` is slightly 
misleading. The test splits the raw bytes mid-`"output_tokens"` key, so neither 
buffer on its own contains a complete SSE event. What actually happens is that 
the streaming JSON-parse path in `processChunk` fails to find a complete event 
in the first buffer and the regex fallback path (`extractUsageTokensFromSse`) 
runs over the accumulated body. Rephrasing to say "neither partial buffer forms 
a complete JSON event, so the regex fallback over the accumulated SSE tail is 
the code path exercised here" would make the coverage intent clear.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to