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

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 7ef2ec7e1 fix(ai): bound request and stream payloads (#2539)
7ef2ec7e1 is described below

commit 7ef2ec7e14b406a630faf43a3f89f3f0c49ab8b5
Author: xdz997 <[email protected]>
AuthorDate: Tue Aug 25 17:28:06 2026 +0800

    fix(ai): bound request and stream payloads (#2539)
---
 .../rocketmq/studio/ops/ai/AiPayloadGuard.java     | 157 +++++++++++++++++++++
 .../apache/rocketmq/studio/ops/ai/AiService.java   |  16 ++-
 .../rocketmq/studio/ops/ai/CliAgentProvider.java   |   4 +
 .../studio/ops/ai/OpenAiCompatibleLlmClient.java   |  65 ++++++++-
 .../rocketmq/studio/ops/ai/AiPayloadGuardTest.java |  77 ++++++++++
 .../rocketmq/studio/ops/ai/AiServiceTest.java      |  46 +++++-
 .../studio/ops/ai/CliAgentProviderTest.java        |  12 ++
 .../ops/ai/OpenAiCompatibleLlmClientTest.java      |  39 +++++
 8 files changed, 409 insertions(+), 7 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiPayloadGuard.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiPayloadGuard.java
new file mode 100644
index 000000000..b8bc68e07
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiPayloadGuard.java
@@ -0,0 +1,157 @@
+/*
+ * 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.rocketmq.studio.ops.ai;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.util.StringUtils;
+
+import java.util.Map;
+
+/**
+ * Central byte budgets for user-controlled AI payloads. Character-count 
validation is not
+ * sufficient here because provider requests, CLI arguments and tool payloads 
are encoded as
+ * UTF-8 before leaving Studio.
+ */
+final class AiPayloadGuard {
+
+    static final int MAX_MESSAGE_BYTES = 64 * 1024;
+    static final int MAX_CONTEXT_BYTES = 256 * 1024;
+    static final int MAX_TOOL_INPUT_BYTES = 256 * 1024;
+    static final int MAX_OUTBOUND_PROMPT_BYTES = MAX_MESSAGE_BYTES + 
MAX_CONTEXT_BYTES + 1024;
+    static final int MAX_MODEL_BYTES = 512;
+    static final int MAX_CONVERSATION_ID_BYTES = 256;
+    static final int MAX_SELECTOR_BYTES = 64;
+    static final int MAX_TOOL_NAME_BYTES = 256;
+
+    private AiPayloadGuard() {
+    }
+
+    static void validateChat(ChatDTO request) {
+        if (request == null) {
+            throw new BusinessException(400, "Chat request is required");
+        }
+        requireText(request.getMessage(), "Chat message is required");
+        requireWithin(request.getMessage(), MAX_MESSAGE_BYTES, "Chat message");
+        requireOptionalWithin(request.getModel(), MAX_MODEL_BYTES, "Chat 
model");
+        requireOptionalWithin(request.getConversationId(), 
MAX_CONVERSATION_ID_BYTES,
+                "Conversation ID");
+        requireOptionalWithin(request.getMode(), MAX_SELECTOR_BYTES, "Chat 
mode");
+        requireOptionalWithin(request.getEngine(), MAX_SELECTOR_BYTES, "Chat 
engine");
+    }
+
+    static void validateCommand(AiCommandDTO command, ObjectMapper 
objectMapper) {
+        if (command == null) {
+            throw new BusinessException(400, "Command request is required");
+        }
+        if (!StringUtils.hasText(command.getPrompt()) && 
!StringUtils.hasText(command.getCommand())) {
+            throw new BusinessException(400, "Command or prompt is required");
+        }
+        requireOptionalWithin(command.getPrompt(), MAX_MESSAGE_BYTES, "Command 
prompt");
+        requireOptionalWithin(command.getCommand(), MAX_MESSAGE_BYTES, 
"Command text");
+        requireOptionalWithin(command.getModel(), MAX_MODEL_BYTES, "Command 
model");
+        requireOptionalWithin(command.getConversationId(), 
MAX_CONVERSATION_ID_BYTES,
+                "Conversation ID");
+        requireOptionalWithin(command.getMode(), MAX_SELECTOR_BYTES, "Command 
mode");
+        requireOptionalWithin(command.getEngine(), MAX_SELECTOR_BYTES, 
"Command engine");
+        requireJsonWithin(command.getContext(), MAX_CONTEXT_BYTES, "Command 
context", objectMapper);
+    }
+
+    static void validateToolInvocation(String name, Map<String, Object> input, 
ObjectMapper objectMapper) {
+        requireText(name, "Tool name is required");
+        requireWithin(name, MAX_TOOL_NAME_BYTES, "Tool name");
+        requireJsonWithin(input, MAX_TOOL_INPUT_BYTES, "Tool input", 
objectMapper);
+    }
+
+    static void validateOutboundPrompt(String prompt, String model) {
+        if (exceedsUtf8Limit(prompt, MAX_OUTBOUND_PROMPT_BYTES)) {
+            throw requestTooLarge("LLM prompt", MAX_OUTBOUND_PROMPT_BYTES);
+        }
+        if (exceedsUtf8Limit(model, MAX_MODEL_BYTES)) {
+            throw requestTooLarge("LLM model", MAX_MODEL_BYTES);
+        }
+    }
+
+    private static void requireJsonWithin(Object value, int limitBytes, String 
field, ObjectMapper objectMapper) {
+        if (value == null) {
+            return;
+        }
+        final int size;
+        try {
+            size = objectMapper.writeValueAsBytes(value).length;
+        } catch (JsonProcessingException exception) {
+            throw new BusinessException(400, field + " must be valid JSON");
+        }
+        if (size > limitBytes) {
+            throw new BusinessException(400, field + " must not exceed " + 
limitBytes + " UTF-8 bytes");
+        }
+    }
+
+    private static void requireText(String value, String message) {
+        if (!StringUtils.hasText(value)) {
+            throw new BusinessException(400, message);
+        }
+    }
+
+    private static void requireOptionalWithin(String value, int limitBytes, 
String field) {
+        if (value != null) {
+            requireWithin(value, limitBytes, field);
+        }
+    }
+
+    private static void requireWithin(String value, int limitBytes, String 
field) {
+        if (exceedsUtf8Limit(value, limitBytes)) {
+            throw new BusinessException(400, field + " must not exceed " + 
limitBytes + " UTF-8 bytes");
+        }
+    }
+
+    private static boolean exceedsUtf8Limit(String value, int limitBytes) {
+        if (value == null) {
+            return false;
+        }
+        int bytes = 0;
+        for (int index = 0; index < value.length(); index++) {
+            char current = value.charAt(index);
+            if (current <= 0x7f) {
+                bytes++;
+            } else if (current <= 0x7ff) {
+                bytes += 2;
+            } else if (Character.isHighSurrogate(current)
+                    && index + 1 < value.length()
+                    && Character.isLowSurrogate(value.charAt(index + 1))) {
+                bytes += 4;
+                index++;
+            } else if (Character.isSurrogate(current)) {
+                // The JDK UTF-8 encoder replaces an unpaired surrogate with a 
one-byte '?'.
+                bytes++;
+            } else {
+                bytes += 3;
+            }
+            if (bytes > limitBytes) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static LlmGatewayException requestTooLarge(String field, int 
limitBytes) {
+        return new LlmGatewayException(400, "llm.request.payload_too_large",
+                field + " exceeded the maximum of " + limitBytes + " UTF-8 
bytes",
+                "Reduce the request size and retry.");
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiService.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiService.java
index 000331437..62ede2ee0 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/AiService.java
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.ops.ai;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
@@ -32,13 +33,11 @@ public class AiService {
 
     private final LlmGateway llmGateway;
     private final McpServerRegistry mcpServerRegistry;
+    private final ObjectMapper objectMapper;
 
 
     public SseEmitter chat(ChatDTO request) {
-        if (request == null) {
-            log.warn("Chat request body is missing");
-            throw new BusinessException(400, "Chat request is required");
-        }
+        AiPayloadGuard.validateChat(request);
         log.info("Chat request received: mode={}, conversationId={}", 
request.getMode(), request.getConversationId());
         return llmGateway.chat(request);
     }
@@ -52,6 +51,14 @@ public class AiService {
                     .result("Command request is required")
                     .build();
         }
+        try {
+            AiPayloadGuard.validateCommand(command, objectMapper);
+        } catch (BusinessException exception) {
+            return AiExecuteResultVO.builder()
+                    .success(false)
+                    .result(exception.getMessage())
+                    .build();
+        }
         log.info("Executing AI command: {}", command.getCommand());
         try {
             String result = llmGateway.execute(command);
@@ -80,6 +87,7 @@ public class AiService {
     }
 
     public Object executeTool(String name, Map<String, Object> input) {
+        AiPayloadGuard.validateToolInvocation(name, input, objectMapper);
         log.info("Executing registered AI tool: {}", name);
         return mcpServerRegistry.execute(name, input);
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
index d2db6d3f1..7e937d720 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/CliAgentProvider.java
@@ -77,6 +77,10 @@ public abstract class CliAgentProvider implements 
AgentProvider {
 
     @Override
     public String complete(LlmConfigVO config, String prompt, String 
modelOverride) {
+        String effectiveModel = StringUtils.hasText(modelOverride)
+                ? modelOverride
+                : config == null ? null : config.getModel();
+        AiPayloadGuard.validateOutboundPrompt(prompt, effectiveModel);
         if (!available()) {
             throw new LlmGatewayException(503, "llm.provider.cli_missing",
                     binaryName() + " CLI is not installed in the server 
runtime",
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
index 1b1b6d228..054a98765 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClient.java
@@ -25,6 +25,7 @@ import org.springframework.util.StringUtils;
 
 import java.io.BufferedReader;
 import java.io.ByteArrayOutputStream;
+import java.io.FilterInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
@@ -84,6 +85,7 @@ public class OpenAiCompatibleLlmClient {
 
     public String complete(LlmConfigVO config, String prompt, String 
modelOverride) {
         validate(config);
+        AiPayloadGuard.validateOutboundPrompt(prompt, effectiveModel(config, 
modelOverride));
         Map<String, Object> requestBody = requestBody(config, prompt, 
modelOverride, false);
         HttpRequest request = request(config, "application/json", requestBody);
         try {
@@ -135,6 +137,7 @@ public class OpenAiCompatibleLlmClient {
 
     public void stream(LlmConfigVO config, String prompt, String 
modelOverride, Consumer<String> tokenConsumer) {
         validate(config);
+        AiPayloadGuard.validateOutboundPrompt(prompt, effectiveModel(config, 
modelOverride));
         Map<String, Object> requestBody = requestBody(config, prompt, 
modelOverride, true);
         HttpRequest request = request(config, "text/event-stream", 
requestBody);
         try {
@@ -143,6 +146,10 @@ public class OpenAiCompatibleLlmClient {
                 throw upstreamException(response.statusCode(), 
checkedBody(response.body().errorBody()));
             }
             parseStreamWithTimeout(response.body().stream(), tokenConsumer);
+        } catch (ResponseLimitException exception) {
+            throw new LlmGatewayException(502, 
"llm.provider.response_too_large",
+                    "LLM provider stream exceeded the maximum of " + 
exception.limitBytes() + " bytes",
+                    "Reduce the provider response size and retry.", exception);
         } catch (HttpTimeoutException exception) {
             throw new LlmGatewayException(504, "llm.provider.timeout",
                     "LLM provider stream timed out",
@@ -231,7 +238,8 @@ public class OpenAiCompatibleLlmClient {
                         new LimitedBodySubscriber(responseBodyLimitBytes()), 
StreamBody::error);
             }
             return HttpResponse.BodySubscribers.mapping(
-                    HttpResponse.BodySubscribers.ofInputStream(), 
StreamBody::stream);
+                    HttpResponse.BodySubscribers.ofInputStream(),
+                    input -> StreamBody.stream(new LimitedInputStream(input, 
responseBodyLimitBytes())));
         };
     }
 
@@ -301,6 +309,10 @@ public class OpenAiCompatibleLlmClient {
         return body;
     }
 
+    private String effectiveModel(LlmConfigVO config, String modelOverride) {
+        return StringUtils.hasText(modelOverride) ? modelOverride : 
config.getModel();
+    }
+
     private URI chatCompletionsUri(LlmConfigVO config) {
         return providerUri(config, CHAT_COMPLETIONS_PATH);
     }
@@ -552,4 +564,55 @@ public class OpenAiCompatibleLlmClient {
             }
         }
     }
+
+    private static final class LimitedInputStream extends FilterInputStream {
+
+        private final int limitBytes;
+        private int bytesRead;
+
+        private LimitedInputStream(InputStream input, int limitBytes) {
+            super(input);
+            this.limitBytes = limitBytes;
+        }
+
+        @Override
+        public int read() throws IOException {
+            int value = super.read();
+            if (value != -1) {
+                addBytes(1);
+            }
+            return value;
+        }
+
+        @Override
+        public int read(byte[] buffer, int offset, int length) throws 
IOException {
+            int allowed = Math.min(length, limitBytes - bytesRead + 1);
+            int read = super.read(buffer, offset, allowed);
+            if (read > 0) {
+                addBytes(read);
+            }
+            return read;
+        }
+
+        private void addBytes(int count) throws ResponseLimitException {
+            bytesRead += count;
+            if (bytesRead > limitBytes) {
+                throw new ResponseLimitException(limitBytes);
+            }
+        }
+    }
+
+    private static final class ResponseLimitException extends IOException {
+
+        private final int limitBytes;
+
+        private ResponseLimitException(int limitBytes) {
+            super("LLM provider stream exceeds " + limitBytes + " bytes");
+            this.limitBytes = limitBytes;
+        }
+
+        private int limitBytes() {
+            return limitBytes;
+        }
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiPayloadGuardTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiPayloadGuardTest.java
new file mode 100644
index 000000000..f4e6b026e
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiPayloadGuardTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.rocketmq.studio.ops.ai;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class AiPayloadGuardTest {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Test
+    void chatMeasuresUtf8BytesAndAcceptsTheExactBoundary() {
+        ChatDTO boundary = ChatDTO.builder()
+                .message("\u754c".repeat(AiPayloadGuard.MAX_MESSAGE_BYTES / 3) 
+ "x")
+                .build();
+        ChatDTO oversized = ChatDTO.builder()
+                .message(boundary.getMessage() + "\u754c")
+                .build();
+
+        assertThatCode(() -> 
AiPayloadGuard.validateChat(boundary)).doesNotThrowAnyException();
+        assertThatThrownBy(() -> AiPayloadGuard.validateChat(oversized))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("UTF-8 bytes");
+    }
+
+    @Test
+    void commandRequiresPromptOrCommand() {
+        AiCommandDTO request = 
AiCommandDTO.builder().context(Map.of("cluster", "main")).build();
+
+        assertThatThrownBy(() -> AiPayloadGuard.validateCommand(request, 
objectMapper))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Command or prompt is required");
+    }
+
+    @Test
+    void toolInputUsesItsSerializedJsonSize() {
+        Map<String, Object> input = Map.of(
+                "payload", "x".repeat(AiPayloadGuard.MAX_TOOL_INPUT_BYTES));
+
+        assertThatThrownBy(() -> 
AiPayloadGuard.validateToolInvocation("rmq.query", input, objectMapper))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("Tool input must not exceed");
+    }
+
+    @Test
+    void outboundModelUsesTheProviderErrorContract() {
+        assertThatThrownBy(() -> AiPayloadGuard.validateOutboundPrompt(
+                "hello", "\u6a21".repeat(AiPayloadGuard.MAX_MODEL_BYTES / 3 + 
1)))
+                .isInstanceOfSatisfying(LlmGatewayException.class, exception 
-> {
+                    assertThat(exception.getStatusCode()).isEqualTo(400);
+                    
assertThat(exception.getCode()).isEqualTo("llm.request.payload_too_large");
+                    assertThat(exception.getMessage()).contains("LLM model");
+                });
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiServiceTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiServiceTest.java
index d98a751f3..d50f9c46c 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiServiceTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/AiServiceTest.java
@@ -16,10 +16,11 @@
  */
 package org.apache.rocketmq.studio.ops.ai;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -46,9 +47,13 @@ class AiServiceTest {
     @Mock
     private McpServerRegistry mcpServerRegistry;
 
-    @InjectMocks
     private AiService aiService;
 
+    @BeforeEach
+    void setUp() {
+        aiService = new AiService(llmGateway, mcpServerRegistry, new 
ObjectMapper());
+    }
+
     @Test
     void chatShouldReturnSseEmitterFromGateway() {
         ChatDTO request = ChatDTO.builder()
@@ -215,6 +220,43 @@ class AiServiceTest {
         verifyNoInteractions(llmGateway);
     }
 
+    @Test
+    void chatRejectsOversizedMessageBeforeCallingGateway() {
+        ChatDTO request = ChatDTO.builder()
+                .message("\u754c".repeat(AiPayloadGuard.MAX_MESSAGE_BYTES / 3 
+ 1))
+                .build();
+
+        assertThatThrownBy(() -> aiService.chat(request))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("Chat message must not exceed");
+        verifyNoInteractions(llmGateway);
+    }
+
+    @Test
+    void executeRejectsOversizedContextBeforeCallingGateway() {
+        AiCommandDTO command = AiCommandDTO.builder()
+                .command("query_metrics")
+                .context(Map.of("payload", 
"x".repeat(AiPayloadGuard.MAX_CONTEXT_BYTES)))
+                .build();
+
+        AiExecuteResultVO result = aiService.execute(command);
+
+        assertThat(result.isSuccess()).isFalse();
+        assertThat(result.getResult()).contains("Command context must not 
exceed");
+        verifyNoInteractions(llmGateway);
+    }
+
+    @Test
+    void executeToolRejectsOversizedInputBeforeCallingRegistry() {
+        Map<String, Object> input = Map.of(
+                "payload", "x".repeat(AiPayloadGuard.MAX_TOOL_INPUT_BYTES));
+
+        assertThatThrownBy(() -> aiService.executeTool("rmq.capabilities", 
input))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("Tool input must not exceed");
+        verifyNoInteractions(mcpServerRegistry);
+    }
+
     @Test
     void executeHandlesNullCommand() {
         AiExecuteResultVO result = aiService.execute(null);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
index 19fc6aac7..12f97eff4 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/CliAgentProviderTest.java
@@ -125,6 +125,18 @@ class CliAgentProviderTest {
         assertThat(cli.complete(null, "prompt", null)).isNotEmpty();
     }
 
+    @Test
+    void completeRejectsOversizedPromptBeforeStartingCli() {
+        FakeCli cli = new FakeCli("echo should-not-run");
+
+        assertThatThrownBy(() -> cli.complete(
+                null, "x".repeat(AiPayloadGuard.MAX_OUTBOUND_PROMPT_BYTES + 
1), null))
+                .isInstanceOfSatisfying(LlmGatewayException.class, exception 
-> {
+                    assertThat(exception.getStatusCode()).isEqualTo(400);
+                    
assertThat(exception.getCode()).isEqualTo("llm.request.payload_too_large");
+                });
+    }
+
     @Test
     void availabilityAndCompletionUseTheIsolatedEnvironment() {
         RecordingEnvironment processEnvironment = new RecordingEnvironment();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
index eb07c2067..f10a908de 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/OpenAiCompatibleLlmClientTest.java
@@ -370,6 +370,45 @@ class OpenAiCompatibleLlmClientTest {
                         
assertThat(exception.getCode()).isEqualTo("llm.provider.response_too_large"));
     }
 
+    @Test
+    void streamShouldRejectOversizedSuccessfulResponse() {
+        client = clientWithLimit(1024);
+        String body = "data: {\"choices\":[{\"delta\":{\"content\":\""
+                + "x".repeat(1024)
+                + "\"}}]}\n\n";
+        server.createContext("/v1/chat/completions",
+                exchange -> respond(exchange, 200, body, "text/event-stream"));
+
+        assertThatThrownBy(() -> client.stream(config("openai", "sk-test"), 
"hello", null, token -> { }))
+                .isInstanceOfSatisfying(LlmGatewayException.class, exception 
-> {
+                    assertThat(exception.getStatusCode()).isEqualTo(502);
+                    
assertThat(exception.getCode()).isEqualTo("llm.provider.response_too_large");
+                    assertThat(exception.getMessage()).contains("1024 bytes");
+                });
+    }
+
+    @Test
+    void completeShouldRejectOversizedPromptBeforeCallingUpstream() {
+        assertThatThrownBy(() -> client.complete(
+                config("openai", "sk-test"),
+                "x".repeat(AiPayloadGuard.MAX_OUTBOUND_PROMPT_BYTES + 1),
+                null))
+                .isInstanceOfSatisfying(LlmGatewayException.class, exception 
-> {
+                    assertThat(exception.getStatusCode()).isEqualTo(400);
+                    
assertThat(exception.getCode()).isEqualTo("llm.request.payload_too_large");
+                });
+    }
+
+    @Test
+    void completeShouldRejectOversizedConfiguredModel() {
+        LlmConfigVO config = config("openai", "sk-test");
+        config.setModel("x".repeat(AiPayloadGuard.MAX_MODEL_BYTES + 1));
+
+        assertThatThrownBy(() -> client.complete(config, "hello", null))
+                .isInstanceOfSatisfying(LlmGatewayException.class, exception ->
+                        
assertThat(exception.getCode()).isEqualTo("llm.request.payload_too_large"));
+    }
+
     @Test
     void completeShouldExposeTimeoutAsGatewayTimeout() {
         OpenAiCompatibleLlmClient timeoutClient = new 
OpenAiCompatibleLlmClient(

Reply via email to