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

hengyuss 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 34f5180c18 feat(ai-proxy): harden reactive transport (#7629)
34f5180c18 is described below

commit 34f5180c18ba5f8bc763f61ba49601916cf59748
Author: hengyuss <[email protected]>
AuthorDate: Thu Oct 15 10:49:48 2026 +0800

    feat(ai-proxy): harden reactive transport (#7629)
---
 .../src/main/resources/application.yml             |  18 ++
 .../plugin/ai/proxy/enhanced/AiProxyPlugin.java    |   3 +
 .../ai/proxy/enhanced/service/AiProxyEngine.java   |   4 +-
 .../transport/AiProxyTransportProperties.java      | 157 ++++++++++++
 .../enhanced/transport/AiTransportException.java   |  68 +++++
 .../transport/WebClientAiProxyTransport.java       | 191 ++++++++++++--
 .../ai/proxy/enhanced/AiProxyPluginTest.java       |  12 +-
 .../transport/WebClientAiProxyTransportTest.java   | 285 +++++++++++++++++++++
 .../ai/proxy/AiProxyPluginConfiguration.java       |  44 +++-
 .../ai/proxy/AiProxyPluginConfigurationTest.java   |  73 +++++-
 10 files changed, 821 insertions(+), 34 deletions(-)

diff --git a/shenyu-bootstrap/src/main/resources/application.yml 
b/shenyu-bootstrap/src/main/resources/application.yml
index e861868a07..cdd1e4b9a3 100644
--- a/shenyu-bootstrap/src/main/resources/application.yml
+++ b/shenyu-bootstrap/src/main/resources/application.yml
@@ -308,6 +308,24 @@ shenyu:
     props:
       jvm_enabled: true
 #  plugins:
+#    ai:
+#      proxy:
+#        transport:
+#          # Maximum time allowed to establish a connection to the AI upstream.
+#          # Default: 30s.
+#          connect-timeout: 30s
+#          # Maximum time to wait for upstream HTTP response headers.
+#          # It does not limit the subsequent SSE body. Default: 30s.
+#          response-header-timeout: 30s
+#          # Maximum interval between two upstream SSE or body chunks.
+#          # The timer restarts after each chunk. Default: 60s.
+#          streaming-idle-timeout: 60s
+#          # Maximum duration of the entire upstream response body stream.
+#          # New chunks do not reset this limit. Default: 10m.
+#          total-stream-duration: 10m
+#          # Maximum cumulative bytes read from one upstream response.
+#          # Zero means unlimited. Default: 0.
+#          max-response-bytes: 0
 #    rate-limiter.enabled: false
   local:
     enabled: false
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPlugin.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPlugin.java
index f3eb14c346..31bcb6d2d4 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPlugin.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPlugin.java
@@ -30,6 +30,7 @@ import 
org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiProxyException;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamResponse;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyConfigService;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyEngine;
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiTransportException;
 import org.apache.shenyu.plugin.api.ShenyuPluginChain;
 import org.apache.shenyu.plugin.base.AbstractShenyuPlugin;
 import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
@@ -139,6 +140,8 @@ public class AiProxyPlugin extends AbstractShenyuPlugin {
             status = HttpStatus.PAYLOAD_TOO_LARGE;
         } else if (error instanceof ShenyuException) {
             status = HttpStatus.BAD_REQUEST;
+        } else if (error instanceof AiTransportException) {
+            status = HttpStatus.BAD_GATEWAY;
         } else if (error instanceof AiProxyException) {
             status = ((AiProxyException) error).getKind() == 
AiProxyException.Kind.REQUEST ? HttpStatus.BAD_REQUEST : HttpStatus.BAD_GATEWAY;
         }
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngine.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngine.java
index b25664b097..42a0811487 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngine.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngine.java
@@ -33,6 +33,7 @@ import 
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactor
 import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProvider;
 import 
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProviderFactory;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransport;
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiTransportException;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
@@ -128,7 +129,8 @@ public final class AiProxyEngine {
                     }).then();
                 })
                 .onErrorResume(error -> {
-                    boolean transportFailure = error instanceof 
AiProxyException
+                    boolean transportFailure = error instanceof 
AiTransportException
+                            || error instanceof AiProxyException
                             && ((AiProxyException) error).getKind() == 
AiProxyException.Kind.TRANSPORT;
                     boolean statusFailure = error instanceof 
UpstreamStatusFailure;
                     if (emitted.get() || !(transportFailure || statusFailure)) 
{
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/AiProxyTransportProperties.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/AiProxyTransportProperties.java
new file mode 100644
index 0000000000..c76939b0f1
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/AiProxyTransportProperties.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.shenyu.plugin.ai.proxy.enhanced.transport;
+
+import java.time.Duration;
+import java.util.Objects;
+
+/**
+ * AI proxy transport properties.
+ */
+public class AiProxyTransportProperties {
+
+    /**
+     * Maximum duration allowed for establishing an upstream connection.
+     */
+    private Duration connectTimeout = Duration.ofSeconds(30);
+
+    /**
+     * Maximum duration allowed for receiving upstream response headers.
+     */
+    private Duration responseHeaderTimeout = Duration.ofSeconds(30);
+
+    /**
+     * Maximum idle duration allowed between upstream response body chunks.
+     */
+    private Duration streamingIdleTimeout = Duration.ofSeconds(60);
+
+    /**
+     * Maximum total duration allowed for an upstream response body stream.
+     */
+    private Duration totalStreamDuration = Duration.ofMinutes(10);
+
+    /**
+     * Maximum total bytes allowed for one upstream response. Zero means 
unlimited.
+     */
+    private long maxResponseBytes;
+
+    /**
+     * Get connect timeout.
+     *
+     * @return connect timeout
+     */
+    public Duration getConnectTimeout() {
+        return connectTimeout;
+    }
+
+    /**
+     * Set connect timeout.
+     *
+     * @param connectTimeout connect timeout
+     */
+    public void setConnectTimeout(final Duration connectTimeout) {
+        this.connectTimeout = requirePositive(connectTimeout, 
"connectTimeout");
+    }
+
+    /**
+     * Get response header timeout.
+     *
+     * @return response header timeout
+     */
+    public Duration getResponseHeaderTimeout() {
+        return responseHeaderTimeout;
+    }
+
+    /**
+     * Set response header timeout.
+     *
+     * @param responseHeaderTimeout response header timeout
+     */
+    public void setResponseHeaderTimeout(final Duration responseHeaderTimeout) 
{
+        this.responseHeaderTimeout = requirePositive(responseHeaderTimeout, 
"responseHeaderTimeout");
+    }
+
+    /**
+     * Get streaming idle timeout.
+     *
+     * @return streaming idle timeout
+     */
+    public Duration getStreamingIdleTimeout() {
+        return streamingIdleTimeout;
+    }
+
+    /**
+     * Set streaming idle timeout.
+     *
+     * @param streamingIdleTimeout streaming idle timeout
+     */
+    public void setStreamingIdleTimeout(final Duration streamingIdleTimeout) {
+        this.streamingIdleTimeout = requirePositive(streamingIdleTimeout, 
"streamingIdleTimeout");
+    }
+
+    /**
+     * Get total stream duration.
+     *
+     * @return total stream duration
+     */
+    public Duration getTotalStreamDuration() {
+        return totalStreamDuration;
+    }
+
+    /**
+     * Set total stream duration.
+     *
+     * @param totalStreamDuration total stream duration
+     */
+    public void setTotalStreamDuration(final Duration totalStreamDuration) {
+        this.totalStreamDuration = requirePositive(totalStreamDuration, 
"totalStreamDuration");
+    }
+
+    /**
+     * Get maximum upstream response bytes.
+     *
+     * @return maximum upstream response bytes, or zero when unlimited
+     */
+    public long getMaxResponseBytes() {
+        return maxResponseBytes;
+    }
+
+    /**
+     * Set maximum upstream response bytes.
+     *
+     * @param maxResponseBytes maximum upstream response bytes, or zero when 
unlimited
+     */
+    public void setMaxResponseBytes(final long maxResponseBytes) {
+        this.maxResponseBytes = requireNonNegative(maxResponseBytes, 
"maxResponseBytes");
+    }
+
+    static Duration requirePositive(final Duration duration, final String 
name) {
+        Objects.requireNonNull(duration, name + " must not be null");
+        if (duration.isZero() || duration.isNegative()) {
+            throw new IllegalArgumentException(name + " must be greater than 
zero");
+        }
+        return duration;
+    }
+
+    static long requireNonNegative(final long value, final String name) {
+        if (value < 0) {
+            throw new IllegalArgumentException(name + " must be greater than 
or equal to zero");
+        }
+        return value;
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/AiTransportException.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/AiTransportException.java
new file mode 100644
index 0000000000..aed6e48b33
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/AiTransportException.java
@@ -0,0 +1,68 @@
+/*
+ * 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.proxy.enhanced.transport;
+
+/**
+ * Classified failure raised by an AI transport.
+ */
+public final class AiTransportException extends RuntimeException {
+
+    private static final long serialVersionUID = 1L;
+
+    private final Kind kind;
+
+    /**
+     * Create an AI transport exception.
+     *
+     * @param kind transport failure kind
+     * @param message failure message
+     * @param cause original failure
+     */
+    public AiTransportException(
+            final Kind kind,
+            final String message,
+            final Throwable cause) {
+        super(message, cause);
+        this.kind = kind;
+    }
+
+    /**
+     * Get transport failure kind.
+     *
+     * @return transport failure kind
+     */
+    public Kind getKind() {
+        return kind;
+    }
+
+    /**
+     * Transport failure classification.
+     */
+    public enum Kind {
+        CONNECT_TIMEOUT,
+        RESPONSE_HEADER_TIMEOUT,
+        STREAM_IDLE_TIMEOUT,
+        STREAM_DURATION_TIMEOUT,
+        RESPONSE_TOO_LARGE,
+        DNS_FAILURE,
+        TLS_FAILURE,
+        CONNECTION_RESET,
+        IO_FAILURE,
+        UNKNOWN
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/WebClientAiProxyTransport.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/WebClientAiProxyTransport.java
index 090966023f..06be8f997f 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/WebClientAiProxyTransport.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/WebClientAiProxyTransport.java
@@ -17,16 +17,26 @@
 
 package org.apache.shenyu.plugin.ai.proxy.enhanced.transport;
 
+import io.netty.channel.ConnectTimeoutException;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamRequest;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamResponse;
-import org.springframework.http.HttpMethod;
 import org.springframework.core.io.buffer.DataBuffer;
 import org.springframework.core.io.buffer.DataBufferUtils;
-import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiProxyException;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
 import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
+import reactor.netty.http.client.PrematureCloseException;
 
+import javax.net.ssl.SSLException;
+import java.io.IOException;
+import java.net.UnknownHostException;
+import java.time.Duration;
 import java.util.Objects;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
 
 /**
  * AI transport implemented with Spring WebClient.
@@ -35,13 +45,40 @@ public final class WebClientAiProxyTransport implements 
AiProxyTransport {
 
     private final WebClient webClient;
 
+    private final Duration responseHeaderTimeout;
+
+    private final Duration streamingIdleTimeout;
+
+    private final Duration totalStreamDuration;
+
+    private final long maxResponseBytes;
+
     /**
      * Create a WebClient transport.
      *
      * @param webClient web client
      */
     public WebClientAiProxyTransport(final WebClient webClient) {
-        this.webClient = webClient;
+        this(webClient, new AiProxyTransportProperties());
+    }
+
+    /**
+     * Create a WebClient transport.
+     *
+     * @param webClient web client
+     * @param properties transport properties
+     */
+    public WebClientAiProxyTransport(final WebClient webClient, final 
AiProxyTransportProperties properties) {
+        this.webClient = Objects.requireNonNull(webClient, "webClient must not 
be null");
+        Objects.requireNonNull(properties, "properties must not be null");
+        this.responseHeaderTimeout = 
AiProxyTransportProperties.requirePositive(
+                properties.getResponseHeaderTimeout(), 
"responseHeaderTimeout");
+        this.streamingIdleTimeout = AiProxyTransportProperties.requirePositive(
+                properties.getStreamingIdleTimeout(), "streamingIdleTimeout");
+        this.totalStreamDuration = AiProxyTransportProperties.requirePositive(
+                properties.getTotalStreamDuration(), "totalStreamDuration");
+        this.maxResponseBytes = AiProxyTransportProperties.requireNonNegative(
+                properties.getMaxResponseBytes(), "maxResponseBytes");
     }
 
     @Override
@@ -65,20 +102,138 @@ public final class WebClientAiProxyTransport implements 
AiProxyTransport {
                 .retrieve()
                 .onRawStatus(status -> status >= 400, response -> Mono.empty())
                 .toEntityFlux(DataBuffer.class)
-                .map(entity -> new AiUpstreamResponse(
-                        entity.getStatusCode().value(),
-                        entity.getHeaders(),
-                        Objects.requireNonNull(entity.getBody()).map(buffer -> 
{
-                            try {
-                                byte[] bytes = new 
byte[buffer.readableByteCount()];
-                                buffer.read(bytes);
-                                return bytes;
-                            } finally {
-                                DataBufferUtils.release(buffer);
-                            }
-                        }).doOnDiscard(DataBuffer.class, 
DataBufferUtils::release)
-                                .onErrorMap(error -> new 
AiProxyException(AiProxyException.Kind.TRANSPORT, "AI upstream body failed", 
error))))
-                .onErrorMap(error -> !(error instanceof AiProxyException),
-                        error -> new 
AiProxyException(AiProxyException.Kind.TRANSPORT, "AI upstream exchange 
failed", error));
+                .timeout(responseHeaderTimeout)
+                .onErrorMap(TimeoutException.class, error -> new 
AiTransportException(
+                        AiTransportException.Kind.RESPONSE_HEADER_TIMEOUT,
+                        "AI upstream response header timed out",
+                        error))
+                .map(entity -> {
+                    final Flux<DataBuffer> rawBody = 
Objects.requireNonNull(entity.getBody());
+                    if (exceedsMaxResponseBytes(entity.getHeaders())) {
+                        return new AiUpstreamResponse(
+                                entity.getStatusCode().value(),
+                                entity.getHeaders(),
+                                rejectOversizedResponse(rawBody));
+                    }
+
+                    final Flux<DataBuffer> dataBuffers = 
enforceTotalStreamDuration(
+                            rawBody.timeout(streamingIdleTimeout)
+                                    .onErrorMap(TimeoutException.class, error 
-> new AiTransportException(
+                                            
AiTransportException.Kind.STREAM_IDLE_TIMEOUT,
+                                            "AI upstream response body became 
idle",
+                                            error)));
+                    final Flux<byte[]> bytes = dataBuffers
+                            .map(buffer -> {
+                                try {
+                                    final byte[] result = new 
byte[buffer.readableByteCount()];
+                                    buffer.read(result);
+                                    return result;
+                                } finally {
+                                    DataBufferUtils.release(buffer);
+                                }
+                            })
+                            .doOnDiscard(DataBuffer.class, 
DataBufferUtils::release)
+                            .onErrorMap(error -> !(error instanceof 
AiTransportException),
+                                    error -> toTransportException("AI upstream 
body failed", error));
+                    return new AiUpstreamResponse(
+                            entity.getStatusCode().value(),
+                            entity.getHeaders(),
+                            enforceMaxResponseBytes(bytes));
+                })
+                .onErrorMap(error -> !(error instanceof AiTransportException),
+                        error -> toTransportException("AI upstream exchange 
failed", error));
+    }
+
+    private Flux<DataBuffer> enforceTotalStreamDuration(final Flux<DataBuffer> 
body) {
+        return Flux.defer(() -> {
+            final AtomicBoolean expired = new AtomicBoolean();
+            final Mono<Long> deadline = Mono.delay(totalStreamDuration)
+                    .doOnNext(ignored -> expired.set(true));
+            return body.takeUntilOther(deadline)
+                    .concatWith(Flux.defer(() -> expired.get()
+                            ? Flux.error(new AiTransportException(
+                                    
AiTransportException.Kind.STREAM_DURATION_TIMEOUT,
+                                    "AI upstream response body exceeded total 
duration",
+                                    new TimeoutException("AI upstream response 
body exceeded total duration")))
+                            : Flux.empty()));
+        });
+    }
+
+    private AiTransportException toTransportException(final String message, 
final Throwable failure) {
+        return new AiTransportException(classify(failure), message, failure);
+    }
+
+    private AiTransportException.Kind classify(final Throwable failure) {
+        if (hasCause(failure, ConnectTimeoutException.class)) {
+            return AiTransportException.Kind.CONNECT_TIMEOUT;
+        }
+        if (hasCause(failure, UnknownHostException.class)) {
+            return AiTransportException.Kind.DNS_FAILURE;
+        }
+        if (hasCause(failure, SSLException.class)) {
+            return AiTransportException.Kind.TLS_FAILURE;
+        }
+        if (hasCause(failure, PrematureCloseException.class)) {
+            return AiTransportException.Kind.CONNECTION_RESET;
+        }
+        if (hasCause(failure, IOException.class)) {
+            return AiTransportException.Kind.IO_FAILURE;
+        }
+        return AiTransportException.Kind.UNKNOWN;
+    }
+
+    private boolean hasCause(final Throwable failure, final Class<? extends 
Throwable> type) {
+        Throwable current = failure;
+        while (Objects.nonNull(current)) {
+            if (type.isInstance(current)) {
+                return true;
+            }
+            Throwable cause = current.getCause();
+            if (cause == current) {
+                return false;
+            }
+            current = cause;
+        }
+        return false;
+    }
+
+    private Flux<byte[]> enforceMaxResponseBytes(final Flux<byte[]> body) {
+        if (maxResponseBytes == 0) {
+            return body;
+        }
+        return Flux.defer(() -> {
+            final AtomicLong receivedBytes = new AtomicLong();
+            return body.handle((bytes, sink) -> {
+                final long received = receivedBytes.get();
+                if (bytes.length > maxResponseBytes - received) {
+                    sink.error(responseTooLarge());
+                    return;
+                }
+                receivedBytes.set(received + bytes.length);
+                sink.next(bytes);
+            });
+        });
+    }
+
+    private boolean exceedsMaxResponseBytes(
+            final HttpHeaders headers) {
+        if (maxResponseBytes == 0) {
+            return false;
+        }
+
+        final long contentLength = headers.getContentLength();
+        return contentLength >= 0
+                && contentLength > maxResponseBytes;
+    }
+
+    private Flux<byte[]> rejectOversizedResponse(final Flux<DataBuffer> body) {
+        return body.take(0, false).thenMany(Flux.error(responseTooLarge()));
+    }
+
+    private AiTransportException responseTooLarge() {
+        return new AiTransportException(
+                AiTransportException.Kind.RESPONSE_TOO_LARGE,
+                "AI upstream response exceeded maximum size",
+                null);
     }
 }
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPluginTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPluginTest.java
index 2a46f0b46a..bda2653b1f 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPluginTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPluginTest.java
@@ -27,7 +27,6 @@ import 
org.apache.shenyu.common.dto.convert.rule.AiProxyHandle;
 import org.apache.shenyu.common.enums.PluginEnum;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.cache.AiProxyApiKeyCache;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.handler.AiProxyPluginHandler;
-import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiProxyException;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamRequest;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamResponse;
 import 
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactory;
@@ -38,7 +37,7 @@ import 
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.OpenAiCompatible;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyConfigService;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyEngine;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransport;
-import org.apache.shenyu.plugin.api.ShenyuPluginChain;
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiTransportException;
 import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -63,7 +62,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.mock;
 
 /**
  * Compatibility tests through AiProxyEngine instead of Spring AI clients.
@@ -247,7 +245,7 @@ class AiProxyPluginTest {
         }).then());
         AiProxyPlugin plugin = plugin(request -> Mono.just(new 
AiUpstreamResponse(200, Map.of(),
                 Flux.just(bytes("data: 
{\"first\":true}\n\n")).concatWith(Flux.error(failure())))));
-        
StepVerifier.create(execute(plugin)).expectError(AiProxyException.class).verify();
+        
StepVerifier.create(execute(plugin)).expectError(AiTransportException.class).verify();
         assertEquals(1, requests.size());
         assertEquals(List.of("data: {\"first\":true}\n\n"), received);
         assertEquals(HttpStatus.OK, exchange.getResponse().getStatusCode());
@@ -289,7 +287,7 @@ class AiProxyPluginTest {
     }
 
     private Mono<Void> execute(final AiProxyPlugin plugin) {
-        return plugin.doExecute(exchange, mock(ShenyuPluginChain.class), 
selector, rule);
+        return plugin.doExecute(exchange, current -> Mono.empty(), selector, 
rule);
     }
 
     private AiUpstreamResponse response(final int status, final String body) {
@@ -309,7 +307,7 @@ class AiProxyPluginTest {
         return config;
     }
 
-    private AiProxyException failure() {
-        return new AiProxyException(AiProxyException.Kind.TRANSPORT, "fixture 
failure", null);
+    private AiTransportException failure() {
+        return new AiTransportException(AiTransportException.Kind.UNKNOWN, 
"fixture failure", null);
     }
 }
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/WebClientAiProxyTransportTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/WebClientAiProxyTransportTest.java
index 2583ac98ca..9625024719 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/WebClientAiProxyTransportTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/transport/WebClientAiProxyTransportTest.java
@@ -17,9 +17,11 @@
 
 package org.apache.shenyu.plugin.ai.proxy.enhanced.transport;
 
+import io.netty.channel.ConnectTimeoutException;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamRequest;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamResponse;
 import org.junit.jupiter.api.Test;
+import org.springframework.core.io.buffer.DataBuffer;
 import org.springframework.core.io.buffer.DefaultDataBufferFactory;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpStatus;
@@ -29,15 +31,24 @@ import 
org.springframework.web.reactive.function.client.ClientResponse;
 import org.springframework.web.reactive.function.client.WebClient;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
+import reactor.netty.http.client.PrematureCloseException;
 import reactor.test.StepVerifier;
 
+import javax.net.ssl.SSLException;
+import java.io.IOException;
 import java.net.URI;
+import java.net.UnknownHostException;
 import java.nio.charset.StandardCharsets;
+import java.time.Duration;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class WebClientAiProxyTransportTest {
 
@@ -87,6 +98,258 @@ class WebClientAiProxyTransportTest {
                 .verifyComplete();
     }
 
+    @Test
+    void testAllowsResponseExactlyAtMaximumSize() {
+        final WebClient webClient = 
webClientWithBody(Flux.just(dataBuffer("ab"), dataBuffer("cde")));
+        final AiProxyTransportProperties properties = new 
AiProxyTransportProperties();
+        properties.setMaxResponseBytes(5);
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(webClient, properties);
+
+        
StepVerifier.create(transport.execute(createRequest()).flatMapMany(AiUpstreamResponse::getBody)
+                        .map(bytes -> new String(bytes, 
StandardCharsets.UTF_8)))
+                .expectNext("ab", "cde")
+                .verifyComplete();
+    }
+
+    @Test
+    void testAllowsContentLengthExactlyAtMaximumSize() {
+        final WebClient webClient = WebClient.builder()
+                .exchangeFunction(request -> 
Mono.just(ClientResponse.create(HttpStatus.OK)
+                        .header(HttpHeaders.CONTENT_LENGTH, "5")
+                        .body(Flux.just(dataBuffer("abcde")))
+                        .build()))
+                .build();
+        final AiProxyTransportProperties properties = new 
AiProxyTransportProperties();
+        properties.setMaxResponseBytes(5);
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(webClient, properties);
+
+        
StepVerifier.create(transport.execute(createRequest()).flatMapMany(AiUpstreamResponse::getBody)
+                        .map(bytes -> new String(bytes, 
StandardCharsets.UTF_8)))
+                .expectNext("abcde")
+                .verifyComplete();
+    }
+
+    @Test
+    void testRejectsOversizedContentLengthAndCancelsBody() {
+        final AtomicBoolean subscribed = new AtomicBoolean();
+        final AtomicBoolean cancelled = new AtomicBoolean();
+        final Flux<DataBuffer> body = Flux.<DataBuffer>never()
+                .doOnSubscribe(ignored -> subscribed.set(true))
+                .doOnCancel(() -> cancelled.set(true));
+        final WebClient webClient = WebClient.builder()
+                .exchangeFunction(request -> 
Mono.just(ClientResponse.create(HttpStatus.OK)
+                        .header(HttpHeaders.CONTENT_LENGTH, "6")
+                        .body(body)
+                        .build()))
+                .build();
+        final AiProxyTransportProperties properties = new 
AiProxyTransportProperties();
+        properties.setMaxResponseBytes(5);
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(webClient, properties);
+
+        
StepVerifier.create(transport.execute(createRequest()).flatMapMany(AiUpstreamResponse::getBody))
+                .expectErrorSatisfies(error -> 
assertEquals(AiTransportException.Kind.RESPONSE_TOO_LARGE,
+                        assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                .verify();
+        assertTrue(subscribed.get());
+        assertTrue(cancelled.get());
+    }
+
+    @Test
+    void testRejectsChunkThatExceedsCumulativeResponseSize() {
+        final AtomicBoolean cancelled = new AtomicBoolean();
+        final Flux<DataBuffer> body = Flux.concat(
+                        Flux.just(dataBuffer("abc"), dataBuffer("def")),
+                        Flux.<DataBuffer>never())
+                .doOnCancel(() -> cancelled.set(true));
+        final WebClient webClient = webClientWithBody(body);
+        final AiProxyTransportProperties properties = new 
AiProxyTransportProperties();
+        properties.setMaxResponseBytes(5);
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(webClient, properties);
+
+        
StepVerifier.create(transport.execute(createRequest()).flatMapMany(AiUpstreamResponse::getBody)
+                        .map(bytes -> new String(bytes, 
StandardCharsets.UTF_8)))
+                .expectNext("abc")
+                .expectErrorSatisfies(error -> 
assertEquals(AiTransportException.Kind.RESPONSE_TOO_LARGE,
+                        assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                .verify();
+        assertTrue(cancelled.get());
+    }
+
+    @Test
+    void testRejectsOversizedFirstChunkWithoutEmittingIt() {
+        final WebClient webClient = 
webClientWithBody(Flux.just(dataBuffer("abcdef")));
+        final AiProxyTransportProperties properties = new 
AiProxyTransportProperties();
+        properties.setMaxResponseBytes(5);
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(webClient, properties);
+
+        
StepVerifier.create(transport.execute(createRequest()).flatMapMany(AiUpstreamResponse::getBody))
+                .expectErrorSatisfies(error -> 
assertEquals(AiTransportException.Kind.RESPONSE_TOO_LARGE,
+                        assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                .verify();
+    }
+
+    @Test
+    void testClassifiesExchangeFailures() {
+        assertFailureKind(new ConnectTimeoutException(), 
AiTransportException.Kind.CONNECT_TIMEOUT);
+        assertFailureKind(new UnknownHostException(), 
AiTransportException.Kind.DNS_FAILURE);
+        assertFailureKind(new SSLException("handshake failed"), 
AiTransportException.Kind.TLS_FAILURE);
+        assertFailureKind(PrematureCloseException.TEST_EXCEPTION, 
AiTransportException.Kind.CONNECTION_RESET);
+        assertFailureKind(new IOException("read failed"), 
AiTransportException.Kind.IO_FAILURE);
+        assertFailureKind(new IllegalStateException("unexpected"), 
AiTransportException.Kind.UNKNOWN);
+    }
+
+    @Test
+    void testClassifiesWrappedBodyFailure() {
+        final WebClient webClient = WebClient.builder()
+                .exchangeFunction(request -> 
Mono.just(ClientResponse.create(HttpStatus.OK)
+                        .body(Flux.error(new IllegalStateException(new 
SSLException("handshake failed"))))
+                        .build()))
+                .build();
+        final AiUpstreamResponse response = new 
WebClientAiProxyTransport(webClient).execute(createRequest()).block();
+
+        StepVerifier.create(response.getBody())
+                .expectErrorSatisfies(error -> 
assertEquals(AiTransportException.Kind.TLS_FAILURE,
+                        assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                .verify();
+    }
+
+    @Test
+    void testResponseHeaderTimeout() {
+        final WebClient webClient = WebClient.builder()
+                .exchangeFunction(request -> Mono.delay(Duration.ofSeconds(10))
+                        
.thenReturn(ClientResponse.create(HttpStatus.OK).build()))
+                .build();
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(
+                webClient, properties(Duration.ofSeconds(5), 
Duration.ofSeconds(60), Duration.ofMinutes(10)));
+
+        StepVerifier.withVirtualTime(() -> transport.execute(createRequest()))
+                .thenAwait(Duration.ofSeconds(5))
+                .expectErrorSatisfies(error -> 
assertEquals(AiTransportException.Kind.RESPONSE_HEADER_TIMEOUT,
+                        assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                .verify();
+    }
+
+    @Test
+    void testResponseHeaderTimeoutDoesNotLimitBodyStream() {
+        final WebClient webClient = WebClient.builder()
+                .exchangeFunction(request -> 
Mono.just(ClientResponse.create(HttpStatus.OK)
+                        .body(Flux.never())
+                        .build()))
+                .build();
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(
+                webClient, properties(Duration.ofSeconds(5), 
Duration.ofSeconds(60), Duration.ofMinutes(10)));
+
+        StepVerifier.withVirtualTime(() -> transport.execute(createRequest()))
+                .expectNextMatches(response -> response.getStatusCode() == 
HttpStatus.OK.value())
+                .verifyComplete();
+    }
+
+    @Test
+    void testStreamingIdleTimeout() {
+        final WebClient webClient = WebClient.builder()
+                .exchangeFunction(request -> 
Mono.just(ClientResponse.create(HttpStatus.OK)
+                        .body(Flux.never())
+                        .build()))
+                .build();
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(
+                webClient, properties(Duration.ofSeconds(5), 
Duration.ofSeconds(10), Duration.ofMinutes(10)));
+
+        StepVerifier.withVirtualTime(() -> transport.execute(createRequest())
+                        .flatMapMany(AiUpstreamResponse::getBody))
+                .thenAwait(Duration.ofSeconds(10))
+                .expectErrorSatisfies(error -> 
assertEquals(AiTransportException.Kind.STREAM_IDLE_TIMEOUT,
+                        assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                .verify();
+    }
+
+    @Test
+    void testStreamingIdleTimeoutRestartsAfterEachChunk() {
+        final Flux<DataBuffer> body = Flux.defer(() -> 
Flux.interval(Duration.ofSeconds(4))
+                .take(2)
+                .map(index -> DefaultDataBufferFactory.sharedInstance.wrap(
+                        ("chunk-" + index).getBytes(StandardCharsets.UTF_8))));
+        final WebClient webClient = WebClient.builder()
+                .exchangeFunction(request -> 
Mono.just(ClientResponse.create(HttpStatus.OK)
+                        .body(body)
+                        .build()))
+                .build();
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(
+                webClient, properties(Duration.ofSeconds(5), 
Duration.ofSeconds(5), Duration.ofMinutes(10)));
+
+        StepVerifier.withVirtualTime(() -> transport.execute(createRequest())
+                        .flatMapMany(AiUpstreamResponse::getBody)
+                        .map(bytes -> new String(bytes, 
StandardCharsets.UTF_8)))
+                .thenAwait(Duration.ofSeconds(8))
+                .expectNext("chunk-0", "chunk-1")
+                .verifyComplete();
+    }
+
+    @Test
+    void testTotalStreamDurationCancelsActiveStream() {
+        final AtomicBoolean cancelled = new AtomicBoolean();
+        final Flux<DataBuffer> body = Flux.defer(() -> 
Flux.interval(Duration.ofSeconds(1))
+                .<DataBuffer>map(index -> 
DefaultDataBufferFactory.sharedInstance.wrap(
+                        ("chunk-" + index).getBytes(StandardCharsets.UTF_8))))
+                .doOnCancel(() -> cancelled.set(true));
+        final WebClient webClient = WebClient.builder()
+                .exchangeFunction(request -> 
Mono.just(ClientResponse.create(HttpStatus.OK)
+                        .body(body)
+                        .build()))
+                .build();
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(
+                webClient, properties(Duration.ofSeconds(5), 
Duration.ofSeconds(2), Duration.ofMillis(4500)));
+
+        StepVerifier.withVirtualTime(() -> transport.execute(createRequest())
+                        .flatMapMany(AiUpstreamResponse::getBody))
+                .thenAwait(Duration.ofMillis(4500))
+                .expectNextCount(4)
+                .expectErrorSatisfies(error -> 
assertEquals(AiTransportException.Kind.STREAM_DURATION_TIMEOUT,
+                        assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                .verify();
+        assertEquals(true, cancelled.get());
+    }
+
+    @Test
+    void testTotalStreamDurationAllowsNormalCompletion() {
+        final Flux<DataBuffer> body = 
Flux.just(DefaultDataBufferFactory.sharedInstance.wrap(
+                "complete".getBytes(StandardCharsets.UTF_8)));
+        final WebClient webClient = WebClient.builder()
+                .exchangeFunction(request -> 
Mono.just(ClientResponse.create(HttpStatus.OK)
+                        .body(body)
+                        .build()))
+                .build();
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(
+                webClient, properties(Duration.ofSeconds(5), 
Duration.ofSeconds(5), Duration.ofSeconds(10)));
+
+        StepVerifier.withVirtualTime(() -> transport.execute(createRequest())
+                        .flatMapMany(AiUpstreamResponse::getBody)
+                        .map(bytes -> new String(bytes, 
StandardCharsets.UTF_8)))
+                .expectNext("complete")
+                .verifyComplete();
+    }
+
+    @Test
+    void testRejectsNonPositiveTimeouts() {
+        final AiProxyTransportProperties properties = new 
AiProxyTransportProperties();
+
+        assertThrows(IllegalArgumentException.class, () -> 
properties.setResponseHeaderTimeout(Duration.ZERO));
+        assertThrows(IllegalArgumentException.class, () -> 
properties.setStreamingIdleTimeout(Duration.ofSeconds(-1)));
+        assertThrows(IllegalArgumentException.class, () -> 
properties.setTotalStreamDuration(Duration.ZERO));
+        assertThrows(IllegalArgumentException.class, () -> 
properties.setMaxResponseBytes(-1));
+    }
+
+    private void assertFailureKind(final Throwable failure, final 
AiTransportException.Kind expected) {
+        final WebClient webClient = WebClient.builder()
+                .exchangeFunction(request -> Mono.error(failure))
+                .build();
+        final WebClientAiProxyTransport transport = new 
WebClientAiProxyTransport(webClient);
+
+        StepVerifier.create(transport.execute(createRequest()))
+                .expectErrorSatisfies(error -> assertEquals(expected,
+                        assertInstanceOf(AiTransportException.class, 
error).getKind()))
+                .verify();
+    }
+
     private AiUpstreamRequest createRequest() {
         final AiUpstreamRequest request = new AiUpstreamRequest();
         request.setMethod("POST");
@@ -97,4 +360,26 @@ class WebClientAiProxyTransportTest {
         request.setBody("{}".getBytes(StandardCharsets.UTF_8));
         return request;
     }
+
+    private WebClient webClientWithBody(final Flux<DataBuffer> body) {
+        return WebClient.builder()
+                .exchangeFunction(request -> 
Mono.just(ClientResponse.create(HttpStatus.OK)
+                        .body(body)
+                        .build()))
+                .build();
+    }
+
+    private DataBuffer dataBuffer(final String value) {
+        return 
DefaultDataBufferFactory.sharedInstance.wrap(value.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private AiProxyTransportProperties properties(final Duration 
responseHeaderTimeout,
+                                                   final Duration 
streamingIdleTimeout,
+                                                   final Duration 
totalStreamDuration) {
+        final AiProxyTransportProperties properties = new 
AiProxyTransportProperties();
+        properties.setResponseHeaderTimeout(responseHeaderTimeout);
+        properties.setStreamingIdleTimeout(streamingIdleTimeout);
+        properties.setTotalStreamDuration(totalStreamDuration);
+        return properties;
+    }
 }
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfiguration.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfiguration.java
index 1708b69a2e..07bf852251 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfiguration.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfiguration.java
@@ -17,6 +17,7 @@
 
 package org.apache.shenyu.springboot.starter.plugin.ai.proxy;
 
+import io.netty.channel.ChannelOption;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyPlugin;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.handler.AiProxyPluginHandler;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocol;
@@ -31,14 +32,18 @@ import 
org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyConfigService;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyEngine;
 import 
org.apache.shenyu.plugin.ai.proxy.enhanced.subscriber.CommonAiProxyApiKeyDataSubscriber;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransport;
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransportProperties;
 import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.WebClientAiProxyTransport;
 import org.apache.shenyu.plugin.api.ShenyuPlugin;
 import org.apache.shenyu.sync.data.api.AiProxyApiKeyDataSubscriber;
 import 
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
 import org.springframework.web.reactive.function.client.WebClient;
+import reactor.netty.http.client.HttpClient;
 
 import java.util.List;
 
@@ -55,9 +60,9 @@ public class AiProxyPluginConfiguration {
     /**
      * Ai proxy plugin.
      *
-     * @param aiProxyConfigService   the aiProxyConfigService
-     * @param aiProxyEngine the aiProxyEngine
-     * @param aiProxyPluginHandler   the aiProxyPluginHandler
+     * @param aiProxyConfigService the aiProxyConfigService
+     * @param aiProxyEngine        the aiProxyEngine
+     * @param aiProxyPluginHandler the aiProxyPluginHandler
      * @return the shenyu plugin
      */
     @Bean
@@ -101,12 +106,12 @@ public class AiProxyPluginConfiguration {
      *
      * @param aiProxyProtocolFactory protocol factory
      * @param aiProxyProviderFactory provider factory
-     * @param aiProxyTransport transport
+     * @param aiProxyTransport       transport
      * @return the AI proxy engine
      */
     @Bean
     public AiProxyEngine aiProxyEngine(final AiProxyProtocolFactory 
aiProxyProtocolFactory,
-            final AiProxyProviderFactory aiProxyProviderFactory, final 
AiProxyTransport aiProxyTransport) {
+                                       final AiProxyProviderFactory 
aiProxyProviderFactory, final AiProxyTransport aiProxyTransport) {
         return new AiProxyEngine(aiProxyProtocolFactory, 
aiProxyProviderFactory, aiProxyTransport);
     }
 
@@ -165,16 +170,19 @@ public class AiProxyPluginConfiguration {
     /**
      * Ai proxy transport.
      *
+     * @param properties AI proxy transport properties
      * @return the AI proxy transport
      */
     @Bean
     @ConditionalOnMissingBean(AiProxyTransport.class)
-    public AiProxyTransport aiProxyTransport() {
+    public AiProxyTransport aiProxyTransport(final AiProxyTransportProperties 
properties) {
         final WebClient webClient = WebClient.builder()
+                .clientConnector(new 
ReactorClientHttpConnector(createHttpClient(properties)))
                 .build();
-        return new WebClientAiProxyTransport(webClient);
+        return new WebClientAiProxyTransport(webClient, properties);
     }
 
+
     /**
      * Create the DeepSeek compatible provider profile.
      *
@@ -215,4 +223,26 @@ public class AiProxyPluginConfiguration {
         return new OpenAiCompatible("Moonshot");
     }
 
+    /**
+     * AI proxy transport properties.
+     *
+     * @return AI proxy transport properties
+     */
+    @Bean
+    @ConfigurationProperties(prefix = "shenyu.plugins.ai.proxy.transport")
+    public AiProxyTransportProperties aiProxyTransportProperties() {
+        return new AiProxyTransportProperties();
+    }
+
+    HttpClient createHttpClient(final AiProxyTransportProperties properties) {
+        return HttpClient.create()
+                .option(
+                        ChannelOption.CONNECT_TIMEOUT_MILLIS,
+                        connectTimeoutMillis(properties)
+                );
+    }
+
+    int connectTimeoutMillis(final AiProxyTransportProperties properties) {
+        return Math.toIntExact(properties.getConnectTimeout().toMillis());
+    }
 }
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfigurationTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfigurationTest.java
index 80aad054f2..9db0b79b02 100644
--- 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfigurationTest.java
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-proxy/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/proxy/AiProxyPluginConfigurationTest.java
@@ -24,20 +24,29 @@ import 
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
 import 
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProviderFactory;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyEngine;
 import org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransport;
+import 
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransportProperties;
 import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import 
org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import reactor.core.publisher.Mono;
 
+import java.time.Duration;
 import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertSame;
 
 /**
- * Auto-configuration for the migrated execution path and provider profiles.
+ * Auto-configuration for the migrated execution path, provider profiles and 
transport properties.
  */
 class AiProxyPluginConfigurationTest {
 
+    private final ApplicationContextRunner contextRunner = new 
ApplicationContextRunner()
+            .withConfiguration(AutoConfigurations.of(
+                    ConfigurationPropertiesAutoConfiguration.class,
+                    AiProxyPluginConfiguration.class));
+
     @Test
     void createsTheMigratedPluginAndAllExistingProviderIdentifiers() {
         new 
ApplicationContextRunner().withUserConfiguration(AiProxyPluginConfiguration.class).run(context
 -> {
@@ -62,4 +71,66 @@ class AiProxyPluginConfigurationTest {
         new 
ApplicationContextRunner().withUserConfiguration(AiProxyPluginConfiguration.class).withPropertyValues("shenyu.plugins.ai.proxy.enabled=false")
                 .run(context -> 
org.assertj.core.api.Assertions.assertThat(context).doesNotHaveBean(AiProxyPlugin.class).doesNotHaveBean(AiProxyEngine.class));
     }
+
+    @Test
+    void shouldUseDefaultConnectTimeout() {
+        contextRunner.run(context -> {
+            AiProxyTransportProperties properties = 
context.getBean(AiProxyTransportProperties.class);
+            assertEquals(Duration.ofSeconds(30), 
properties.getConnectTimeout());
+            assertEquals(Duration.ofSeconds(30), 
properties.getResponseHeaderTimeout());
+            assertEquals(Duration.ofSeconds(60), 
properties.getStreamingIdleTimeout());
+            assertEquals(Duration.ofMinutes(10), 
properties.getTotalStreamDuration());
+            assertEquals(0, properties.getMaxResponseBytes());
+        });
+    }
+
+    @Test
+    void shouldBindConfiguredConnectTimeout() {
+        contextRunner
+                .withPropertyValues(
+                        "shenyu.plugins.ai.proxy.transport.connect-timeout=5s",
+                        
"shenyu.plugins.ai.proxy.transport.response-header-timeout=7s",
+                        
"shenyu.plugins.ai.proxy.transport.streaming-idle-timeout=11s",
+                        
"shenyu.plugins.ai.proxy.transport.total-stream-duration=12m",
+                        
"shenyu.plugins.ai.proxy.transport.max-response-bytes=1024")
+                .run(context -> {
+                    AiProxyTransportProperties properties = 
context.getBean(AiProxyTransportProperties.class);
+                    assertEquals(Duration.ofSeconds(5), 
properties.getConnectTimeout());
+                    assertEquals(Duration.ofSeconds(7), 
properties.getResponseHeaderTimeout());
+                    assertEquals(Duration.ofSeconds(11), 
properties.getStreamingIdleTimeout());
+                    assertEquals(Duration.ofMinutes(12), 
properties.getTotalStreamDuration());
+                    assertEquals(1024, properties.getMaxResponseBytes());
+                });
+    }
+
+    @Test
+    void shouldConvertConnectTimeoutToNettyMilliseconds() {
+        final AiProxyTransportProperties properties = new 
AiProxyTransportProperties();
+        properties.setConnectTimeout(Duration.ofSeconds(5));
+
+        final int timeout = new AiProxyPluginConfiguration()
+                .connectTimeoutMillis(properties);
+
+        assertEquals(5000, timeout);
+    }
+
+    @Test
+    void shouldRejectNonPositiveTransportTimeouts() {
+        for (String property : List.of(
+                "connect-timeout",
+                "response-header-timeout",
+                "streaming-idle-timeout",
+                "total-stream-duration")) {
+            contextRunner
+                    .withPropertyValues("shenyu.plugins.ai.proxy.transport." + 
property + "=0s")
+                    .run(context -> 
org.assertj.core.api.Assertions.assertThat(context).hasFailed());
+        }
+    }
+
+    @Test
+    void shouldRejectNegativeMaximumResponseBytes() {
+        contextRunner
+                
.withPropertyValues("shenyu.plugins.ai.proxy.transport.max-response-bytes=-1")
+                .run(context -> 
org.assertj.core.api.Assertions.assertThat(context).hasFailed());
+    }
 }

Reply via email to