diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverterFactory.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverterFactory.java index 53646589eaea..78bac2e26c7a 100644 --- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverterFactory.java +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverterFactory.java @@ -23,6 +23,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.Optional; /** * Registry for directional AI protocol converters. @@ -58,9 +59,6 @@ public AiProtocolConverterFactory(final Collection converte */ public AiProtocolConverter getConverter(final String sourceProtocol, final String targetProtocol) { ConversionKey key = new ConversionKey(sourceProtocol, targetProtocol); - if (sourceProtocol.equals(targetProtocol)) { - return new IdentityAiProtocolConverter(sourceProtocol); - } AiProtocolConverter converter = converters.get(key); if (Objects.isNull(converter)) { throw new ShenyuException("Unsupported AI protocol conversion: " @@ -70,15 +68,16 @@ public AiProtocolConverter getConverter(final String sourceProtocol, final Strin } /** - * Resolve a converter from a client protocol to one of the protocols natively accepted by a provider. - * Native support takes precedence over registered conversion routes. When multiple routes are available, - * converter registration order determines the selected target. + * Find a registered converter from a client protocol to one of the Provider-native protocols. + * Converter registration order determines the selected target when multiple routes are available. + * Native identity routes are intentionally outside this registry. * * @param sourceProtocol client protocol identifier * @param targetProtocols protocols natively accepted by the provider - * @return identity or directional protocol converter + * @return matching directional converter, or empty when no conversion route exists */ - public AiProtocolConverter resolveConverter(final String sourceProtocol, final Collection targetProtocols) { + public Optional findConverter(final String sourceProtocol, + final Collection targetProtocols) { Objects.requireNonNull(sourceProtocol, "Source AI protocol is required"); Objects.requireNonNull(targetProtocols, "Target AI protocols are required"); if (sourceProtocol.isBlank()) { @@ -90,21 +89,17 @@ public AiProtocolConverter resolveConverter(final String sourceProtocol, final C throw new IllegalArgumentException("Target AI protocol must not be blank"); } } - if (targetProtocols.contains(sourceProtocol)) { - return new IdentityAiProtocolConverter(sourceProtocol); - } for (Map.Entry entry : converters.entrySet()) { if (entry.getKey().sourceProtocol().equals(sourceProtocol) && targetProtocols.contains(entry.getKey().targetProtocol())) { - return entry.getValue(); + return Optional.of(entry.getValue()); } } - throw new ShenyuException("Unsupported AI protocol conversion from " + sourceProtocol - + " to provider protocols " + targetProtocols); + return Optional.empty(); } /** - * Determine whether an identity or registered directional route is supported. + * Determine whether a registered directional conversion is supported. * * @param sourceProtocol source protocol identifier * @param targetProtocol target protocol identifier @@ -115,8 +110,7 @@ public boolean supports(final String sourceProtocol, final String targetProtocol || sourceProtocol.isBlank() || targetProtocol.isBlank()) { return false; } - return sourceProtocol.equals(targetProtocol) - || converters.containsKey(new ConversionKey(sourceProtocol, targetProtocol)); + return converters.containsKey(new ConversionKey(sourceProtocol, targetProtocol)); } private record ConversionKey(String sourceProtocol, String targetProtocol) { @@ -129,4 +123,5 @@ private record ConversionKey(String sourceProtocol, String targetProtocol) { } } } + } diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/IdentityAiProtocolConverter.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/IdentityAiProtocolConverter.java deleted file mode 100644 index 6ad971d2ed65..000000000000 --- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/IdentityAiProtocolConverter.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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.conversion; - -import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiProxyException; -import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest; - -import java.util.Objects; - -/** - * Identity conversion for requests whose source and target protocols are the same. - */ -public final class IdentityAiProtocolConverter implements AiProtocolConverter { - - private final String protocol; - - /** - * Create an identity converter for one protocol. - * - * @param protocol protocol identifier - */ - public IdentityAiProtocolConverter(final String protocol) { - this.protocol = Objects.requireNonNull(protocol, "AI protocol is required"); - if (protocol.isBlank()) { - throw new IllegalArgumentException("AI protocol must not be blank"); - } - } - - @Override - public String sourceProtocol() { - return protocol; - } - - @Override - public String targetProtocol() { - return protocol; - } - - @Override - public AiProtocolConversion convert(final ShenyuAiRequest request, final boolean stream) { - Objects.requireNonNull(request, "AI request is required"); - if (!protocol.equals(request.getProtocol())) { - throw new AiProxyException(AiProxyException.Kind.REQUEST, - "Expected AI protocol: " + protocol, null); - } - return AiProtocolConversion.identity(request); - } -} diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/routing/AiProtocolRoute.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/routing/AiProtocolRoute.java new file mode 100644 index 000000000000..e5d10a93d5ad --- /dev/null +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/routing/AiProtocolRoute.java @@ -0,0 +1,51 @@ +/* + * 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.routing; + +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConversion; + +import java.util.Objects; + +/** + * Request-scoped route from a client protocol to one Provider-native target protocol. + * + * @param clientProtocol protocol accepted from the client + * @param targetProtocol protocol sent to the Provider + * @param conversion converted request and reverse response conversion + */ +public record AiProtocolRoute( + String clientProtocol, + String targetProtocol, + AiProtocolConversion conversion) { + + /** + * Reject incomplete or inconsistent routes. + */ + public AiProtocolRoute { + Objects.requireNonNull(clientProtocol, "Client AI protocol is required"); + Objects.requireNonNull(targetProtocol, "Target AI protocol is required"); + Objects.requireNonNull(conversion, "AI protocol conversion is required"); + if (clientProtocol.isBlank() || targetProtocol.isBlank()) { + throw new IllegalArgumentException("AI route protocol identifiers must not be blank"); + } + if (!targetProtocol.equals(conversion.request().getProtocol())) { + throw new IllegalArgumentException("Converted AI request protocol does not match route target: " + + conversion.request().getProtocol() + " != " + targetProtocol); + } + } +} diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/routing/AiProtocolRouteResolver.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/routing/AiProtocolRouteResolver.java new file mode 100644 index 000000000000..80940e257da0 --- /dev/null +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/routing/AiProtocolRouteResolver.java @@ -0,0 +1,79 @@ +/* + * 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.routing; + +import org.apache.shenyu.common.exception.ShenyuException; +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConversion; +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConverter; +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConverterFactory; +import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest; + +import java.util.Collection; +import java.util.Objects; + +/** + * Resolves a client protocol to one Provider-native protocol, preferring native passthrough. + */ +public final class AiProtocolRouteResolver { + + private final AiProtocolConverterFactory converterFactory; + + /** + * Create a route resolver backed by the directional converter registry. + * + * @param converterFactory converter registry + */ + public AiProtocolRouteResolver(final AiProtocolConverterFactory converterFactory) { + this.converterFactory = Objects.requireNonNull(converterFactory, "converterFactory must not be null"); + } + + /** + * Resolve and execute the request side of one request-scoped protocol route. + * + * @param request decoded client request + * @param nativeProtocols protocols accepted natively by the Provider + * @param stream effective streaming mode + * @return selected route and its bidirectional conversion + */ + public AiProtocolRoute resolve(final ShenyuAiRequest request, final Collection nativeProtocols, + final boolean stream) { + Objects.requireNonNull(request, "AI request is required"); + Objects.requireNonNull(nativeProtocols, "Provider-native AI protocols are required"); + String clientProtocol = request.getProtocol(); + if (Objects.isNull(clientProtocol) || clientProtocol.isBlank()) { + throw new IllegalArgumentException("Client AI protocol is required"); + } + for (String nativeProtocol : nativeProtocols) { + Objects.requireNonNull(nativeProtocol, "Provider-native AI protocol is required"); + if (nativeProtocol.isBlank()) { + throw new IllegalArgumentException("Provider-native AI protocol must not be blank"); + } + } + + if (nativeProtocols.contains(clientProtocol)) { + return new AiProtocolRoute(clientProtocol, clientProtocol, AiProtocolConversion.identity(request)); + } + + AiProtocolConverter converter = converterFactory.findConverter(clientProtocol, nativeProtocols) + .orElseThrow(() -> new ShenyuException("Unsupported AI protocol route from " + clientProtocol + + " to provider protocols " + nativeProtocols)); + AiProtocolConversion conversion = converter.convert(request, stream); + + return new AiProtocolRoute(clientProtocol, converter.targetProtocol(), conversion); + } +} 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 2fdc3055e015..0b2d6c7082cf 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 @@ -24,9 +24,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.shenyu.common.exception.ShenyuException; import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig; -import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConversion; -import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConverter; -import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConverterFactory; import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiResponseAdapter; import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiProxyException; import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamRequest; @@ -35,6 +32,8 @@ import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest; import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocol; import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactory; +import org.apache.shenyu.plugin.ai.proxy.enhanced.routing.AiProtocolRoute; +import org.apache.shenyu.plugin.ai.proxy.enhanced.routing.AiProtocolRouteResolver; 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; @@ -61,7 +60,7 @@ public final class AiProxyEngine { private final AiProxyProviderFactory aiProxyProviderFactory; - private final AiProtocolConverterFactory aiProtocolConverterFactory; + private final AiProtocolRouteResolver aiProtocolRouteResolver; private final AiProxyTransport aiProxyTransport; @@ -70,30 +69,30 @@ public final class AiProxyEngine { public AiProxyEngine( final AiProxyProtocolFactory protocols, final AiProxyProviderFactory providers, - final AiProtocolConverterFactory converters, + final AiProtocolRouteResolver routeResolver, final AiProxyTransport transport) { this( protocols, providers, - converters, + routeResolver, transport, new AiProxyRetryProperties()); } public AiProxyEngine(final AiProxyProtocolFactory protocols, final AiProxyProviderFactory providers, - final AiProtocolConverterFactory converters, final AiProxyTransport transport, + final AiProtocolRouteResolver routeResolver, final AiProxyTransport transport, final AiProxyRetryProperties retryProperties) { this.aiProxyProtocolFactory = Objects.requireNonNull(protocols, "protocols must not be null"); this.aiProxyProviderFactory = Objects.requireNonNull(providers, "providers must not be null"); - this.aiProtocolConverterFactory = Objects.requireNonNull(converters, "converters must not be null"); + this.aiProtocolRouteResolver = Objects.requireNonNull(routeResolver, "routeResolver must not be null"); this.aiProxyTransport = Objects.requireNonNull(transport, "transport must not be null"); this.retryProperties = Objects.requireNonNull(retryProperties, "retryProperties must not be null"); } public AiProxyEngine(final AiProxyProtocolFactory protocols, final AiProxyProviderFactory providers, - final AiProtocolConverterFactory converters, final AiProxyTransport transport, + final AiProtocolRouteResolver routeResolver, final AiProxyTransport transport, final Duration retryBackoff) { - this(protocols, providers, converters, transport, new AiProxyRetryProperties(3, 1, retryBackoff)); + this(protocols, providers, routeResolver, transport, new AiProxyRetryProperties(3, 1, retryBackoff)); } /** @@ -236,10 +235,9 @@ private PreparedExchange prepare(final String requestBody, final AiCommonConfig AiProxyProvider provider = aiProxyProviderFactory.getProvider( Objects.isNull(config.getProvider()) || config.getProvider().isBlank() ? "openai" : config.getProvider()); boolean stream = decoded.hasStream() ? decoded.isStream() : Boolean.TRUE.equals(config.getStream()); - AiProtocolConverter converter = aiProtocolConverterFactory.resolveConverter( - decoded.getProtocol(), provider.capabilities().protocols()); - AiProtocolConversion conversion = converter.convert(decoded, stream); - ShenyuAiRequest providerRequest = conversion.request(); + AiProtocolRoute route = aiProtocolRouteResolver.resolve( + decoded, provider.capabilities().protocols(), stream); + ShenyuAiRequest providerRequest = route.conversion().request(); if (!provider.supports(providerRequest.getProtocol())) { throw new ShenyuException("Unsupported AI upstream protocol/provider combination: " + providerRequest.getProtocol() + " -> " + provider.name()); @@ -248,8 +246,10 @@ private PreparedExchange prepare(final String requestBody, final AiCommonConfig try { PreparedAiRequest preparedRequest = provider.prepareRequest(providerRequest, config); AiResponseAdapter responseAdapter = response -> protocol.encodeResponse( - conversion.responseAdapter().adapt(preparedRequest.responseAdapter().adapt(response)), stream); - return new PreparedExchange(preparedRequest.request(), responseAdapter, protocol.name(), stream); + route.conversion().responseAdapter().adapt( + preparedRequest.responseAdapter().adapt(response)), stream); + return new PreparedExchange(preparedRequest.request(), responseAdapter, + route.clientProtocol(), route.targetProtocol(), stream); } catch (IllegalArgumentException error) { throw new AiProxyException(AiProxyException.Kind.PROVIDER, "Invalid AI provider configuration", error); } @@ -271,6 +271,7 @@ private record PreparedExchange( AiUpstreamRequest request, AiResponseAdapter responseAdapter, String clientProtocol, + String targetProtocol, boolean stream) { } diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyHttpCompatibilityTest.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyHttpCompatibilityTest.java index ba9cd6940e19..5fec678283f9 100644 --- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyHttpCompatibilityTest.java +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyHttpCompatibilityTest.java @@ -54,7 +54,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolConverterFactory; +import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolRouteResolver; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -259,7 +259,7 @@ private AiProxyPlugin plugin(final DisposableServer server, final boolean stream private AiProxyPlugin plugin(final AiProxyPluginHandler handler) { AiProxyEngine engine = new AiProxyEngine(new AiProxyProtocolFactory(List.of(new OpenAiChat(), new OpenAiResponses())), new AiProxyProviderFactory(List.of(new OpenAi())), - protocolConverterFactory(), new WebClientAiProxyTransport(WebClient.builder().build()), Duration.ZERO); + protocolRouteResolver(), new WebClientAiProxyTransport(WebClient.builder().build()), Duration.ZERO); return new AiProxyPlugin(new AiProxyConfigService(handler), engine, handler); } 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 416839bf9e24..e9cf6324ee96 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 @@ -61,7 +61,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolConverterFactory; +import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolRouteResolver; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -280,7 +280,7 @@ void testGetOrderAndNamed() { private AiProxyPlugin plugin(final AiProxyTransport transport) { handler.getSelectorCachedHandle().cachedHandle(CacheKeyUtils.INST.getKey(SELECTOR_ID, Constants.DEFAULT_RULE), handle); AiProxyEngine engine = new AiProxyEngine(new AiProxyProtocolFactory(List.of(new OpenAiChat())), - new AiProxyProviderFactory(List.of(new OpenAi(), new OpenAiCompatible("DeepSeek"))), protocolConverterFactory(), request -> { + new AiProxyProviderFactory(List.of(new OpenAi(), new OpenAiCompatible("DeepSeek"))), protocolRouteResolver(), request -> { requests.add(request); return transport.execute(request); }, Duration.ZERO); diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyTestUtils.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyTestUtils.java index bba2f6be8f4b..c6c3f3c70886 100644 --- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyTestUtils.java +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyTestUtils.java @@ -19,6 +19,7 @@ import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConverterFactory; import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.ConnectResponsesToChatConverter; +import org.apache.shenyu.plugin.ai.proxy.enhanced.routing.AiProtocolRouteResolver; import java.util.List; @@ -31,11 +32,13 @@ private AiProxyTestUtils() { } /** - * Creates the protocol converter factory used by the default Spring configuration. + * Creates the protocol route resolver used by the default Spring configuration. * - * @return protocol converter factory + * @return protocol route resolver */ - public static AiProtocolConverterFactory protocolConverterFactory() { - return new AiProtocolConverterFactory(List.of(new ConnectResponsesToChatConverter())); + public static AiProtocolRouteResolver protocolRouteResolver() { + AiProtocolConverterFactory converterFactory = + new AiProtocolConverterFactory(List.of(new ConnectResponsesToChatConverter())); + return new AiProtocolRouteResolver(converterFactory); } } diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverterFactoryTest.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverterFactoryTest.java index 3e4de4db2d18..c818096ed983 100644 --- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverterFactoryTest.java +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverterFactoryTest.java @@ -56,35 +56,24 @@ void testRejectsUnsupportedRoute() { } @Test - void testSupportsIdentityWithoutRegistration() { + void testDoesNotTreatIdentityAsRegisteredConversion() { AiProtocolConverterFactory factory = new AiProtocolConverterFactory(List.of()); - assertTrue(factory.supports("openai-chat", "openai-chat")); + assertFalse(factory.supports("openai-chat", "openai-chat")); assertFalse(factory.supports("openai-responses", "openai-chat")); + assertThrows(ShenyuException.class, + () -> factory.getConverter("openai-chat", "openai-chat")); } @Test - void testCreatesIdentityConverterWithoutRegistration() { - AiProtocolConverterFactory factory = new AiProtocolConverterFactory(List.of()); - ShenyuAiRequest request = new ShenyuAiRequest(); - request.setProtocol("openai-chat"); - - AiProtocolConverter converter = factory.getConverter("openai-chat", "openai-chat"); - AiProtocolConversion conversion = converter.convert(request, false); - - assertTrue(converter instanceof IdentityAiProtocolConverter); - assertSame(request, conversion.request()); - } - - @Test - void testPrefersNativeProviderProtocolOverConversion() { + void testFindsRegisteredRouteToAnyProviderProtocol() { AiProtocolConverter converter = new TestConverter("openai-responses", "openai-chat"); AiProtocolConverterFactory factory = new AiProtocolConverterFactory(List.of(converter)); - AiProtocolConverter resolved = factory.resolveConverter( - "openai-responses", Set.of("openai-chat", "openai-responses")); + AiProtocolConverter resolved = factory.findConverter( + "openai-responses", Set.of("openai-chat")).orElseThrow(); - assertTrue(resolved instanceof IdentityAiProtocolConverter); + assertSame(converter, resolved); } @Test @@ -93,19 +82,18 @@ void testSelectsRegisteredRouteToAnyProviderProtocol() { AiProtocolConverter second = new TestConverter("source", "target-a"); AiProtocolConverterFactory factory = new AiProtocolConverterFactory(List.of(first, second)); - AiProtocolConverter resolved = factory.resolveConverter( - "source", Set.of("target-a", "target-b")); + AiProtocolConverter resolved = factory.findConverter( + "source", Set.of("target-a", "target-b")).orElseThrow(); assertSame(first, resolved); } @Test - void testRejectsSourceWithoutRouteToProviderProtocols() { + void testReturnsEmptyWithoutRouteToProviderProtocols() { AiProtocolConverterFactory factory = new AiProtocolConverterFactory(List.of( new TestConverter("source", "other"))); - assertThrows(ShenyuException.class, - () -> factory.resolveConverter("source", Set.of("target-a", "target-b"))); + assertTrue(factory.findConverter("source", Set.of("target-a", "target-b")).isEmpty()); } @Test diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProxyProviderHttpTest.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProxyProviderHttpTest.java index 092c9fc97c07..2a2e3f878ed2 100644 --- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProxyProviderHttpTest.java +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProxyProviderHttpTest.java @@ -41,7 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolConverterFactory; +import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolRouteResolver; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -130,7 +130,7 @@ void deepSeekChatStreamConvertsReasoningAndUsageForResponsesClients() throws Exc private AiProxyEngine engine(final AiProxyProvider provider) { return new AiProxyEngine(new AiProxyProtocolFactory(List.of(new OpenAiChat(), new OpenAiResponses())), - new AiProxyProviderFactory(List.of(provider)), protocolConverterFactory(), + new AiProxyProviderFactory(List.of(provider)), protocolRouteResolver(), new WebClientAiProxyTransport(WebClient.builder().build())); } diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiProviderOptionsTest.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiProviderOptionsTest.java index e8481a7876b0..f3dce6bca8fc 100644 --- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiProviderOptionsTest.java +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiProviderOptionsTest.java @@ -47,7 +47,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; -import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolConverterFactory; +import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolRouteResolver; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -263,7 +263,7 @@ void unsupportedOperationsFailBeforeTransportWithoutEngineChanges() { Map.of(), Map.of(), Map.of(), MAPPER.createObjectNode(), restricted, "")); AtomicInteger calls = new AtomicInteger(); AiProxyEngine engine = new AiProxyEngine(new AiProxyProtocolFactory(List.of(new OpenAiResponses())), - new AiProxyProviderFactory(List.of(custom)), protocolConverterFactory(), request -> { + new AiProxyProviderFactory(List.of(custom)), protocolRouteResolver(), request -> { calls.incrementAndGet(); return Mono.empty(); }); diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/routing/AiProtocolRouteResolverTest.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/routing/AiProtocolRouteResolverTest.java new file mode 100644 index 000000000000..ba93c4dac30b --- /dev/null +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/routing/AiProtocolRouteResolverTest.java @@ -0,0 +1,144 @@ +/* + * 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.routing; + +import org.apache.shenyu.common.exception.ShenyuException; +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConversion; +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConverter; +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConverterFactory; +import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamResponse; +import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.Map; +import java.util.Set; +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AiProtocolRouteResolverTest { + + @Test + void testPrefersNativeRouteWithoutInvokingConverter() { + AtomicBoolean invoked = new AtomicBoolean(); + AiProtocolConverter converter = converter("client", "native", invoked); + AiProtocolRouteResolver resolver = resolver(converter); + ShenyuAiRequest request = request("client"); + + AiProtocolRoute route = resolver.resolve(request, Set.of("native", "client"), false); + + assertEquals("client", route.clientProtocol()); + assertEquals("client", route.targetProtocol()); + assertSame(request, route.conversion().request()); + assertFalse(invoked.get()); + } + + @Test + void testBindsConvertedRequestAndReverseResponseAdapter() { + AtomicBoolean invoked = new AtomicBoolean(); + AiProtocolRouteResolver resolver = resolver(converter("client", "native", invoked)); + + AiProtocolRoute route = resolver.resolve(request("client"), Set.of("native"), true); + final AiUpstreamResponse response = new AiUpstreamResponse(200, Map.of(), Flux.empty()); + + assertEquals("client", route.clientProtocol()); + assertEquals("native", route.targetProtocol()); + assertEquals("native", route.conversion().request().getProtocol()); + assertSame(response, route.conversion().responseAdapter().adapt(response)); + assertTrue(invoked.get()); + } + + @Test + void testUsesConverterRegistrationOrderForMultipleTargets() { + AiProtocolConverter first = converter("client", "target-b", new AtomicBoolean()); + AiProtocolConverter second = converter("client", "target-a", new AtomicBoolean()); + AiProtocolRouteResolver resolver = resolver(first, second); + + AiProtocolRoute route = resolver.resolve(request("client"), Set.of("target-a", "target-b"), false); + + assertEquals("target-b", route.targetProtocol()); + } + + @Test + void testRejectsUnsupportedRoute() { + AiProtocolRouteResolver resolver = resolver( + converter("client", "other", new AtomicBoolean())); + + assertThrows(ShenyuException.class, + () -> resolver.resolve(request("client"), Set.of("native"), false)); + } + + @Test + void testRejectsConverterReturningWrongTargetProtocol() { + AiProtocolConverter converter = new AiProtocolConverter() { + @Override + public String sourceProtocol() { + return "client"; + } + + @Override + public String targetProtocol() { + return "native"; + } + + @Override + public AiProtocolConversion convert(final ShenyuAiRequest request, final boolean stream) { + return AiProtocolConversion.identity(request); + } + }; + + assertThrows(IllegalArgumentException.class, + () -> resolver(converter).resolve(request("client"), Set.of("native"), false)); + } + + private AiProtocolRouteResolver resolver(final AiProtocolConverter... converters) { + return new AiProtocolRouteResolver(new AiProtocolConverterFactory(List.of(converters))); + } + + private AiProtocolConverter converter(final String source, final String target, final AtomicBoolean invoked) { + return new AiProtocolConverter() { + @Override + public String sourceProtocol() { + return source; + } + + @Override + public String targetProtocol() { + return target; + } + + @Override + public AiProtocolConversion convert(final ShenyuAiRequest request, final boolean stream) { + invoked.set(true); + return new AiProtocolConversion(request(target), response -> response); + } + }; + } + + private ShenyuAiRequest request(final String protocol) { + ShenyuAiRequest request = new ShenyuAiRequest(); + request.setProtocol(protocol); + return request; + } +} diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineOrchestrationTest.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineOrchestrationTest.java index 9297d904ddd5..56d2d47a0e4f 100644 --- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineOrchestrationTest.java +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineOrchestrationTest.java @@ -57,7 +57,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolConverterFactory; +import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolRouteResolver; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -471,7 +471,7 @@ public AiUpstreamResponse encodeResponse(final AiUpstreamResponse response, fina AiCommonConfig config = config(false, "model"); config.setProvider("custom"); AiProxyEngine engine = new AiProxyEngine(new AiProxyProtocolFactory(List.of(protocol)), new AiProxyProviderFactory(List.of(provider)), - protocolConverterFactory(), request -> Mono.just(response(200, Flux.just(bytes("ok")))), Duration.ZERO); + protocolRouteResolver(), request -> Mono.just(response(200, Flux.just(bytes("ok")))), Duration.ZERO); StepVerifier.create(engine.execute(BODY, config, Optional.empty(), response -> response.getBody().then())).verifyComplete(); assertEquals(List.of("provider", "protocol"), conversions); supported.set(false); @@ -500,13 +500,13 @@ void cancellationDuringFallbackReachesOnlyTheActiveBody() { private AiProxyEngine engine(final AiProxyTransport transport) { return new AiProxyEngine(new AiProxyProtocolFactory(List.of(new OpenAiChat(), new OpenAiResponses())), new AiProxyProviderFactory(List.of(new OpenAi(), new OpenAiCompatible("DeepSeek"))), - protocolConverterFactory(), transport, Duration.ZERO); + protocolRouteResolver(), transport, Duration.ZERO); } private AiProxyEngine engine(final AiProxyTransport transport, final AiProxyRetryProperties retryProperties) { return new AiProxyEngine(new AiProxyProtocolFactory(List.of(new OpenAiChat())), new AiProxyProviderFactory(List.of(new OpenAi(), new OpenAiCompatible("DeepSeek"))), - protocolConverterFactory(), transport, retryProperties); + protocolRouteResolver(), transport, retryProperties); } private AiCommonConfig config(final boolean stream, final String model) { diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineTest.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineTest.java index fc6817ba8670..d908fa52368e 100644 --- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineTest.java +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineTest.java @@ -21,11 +21,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.shenyu.common.exception.ShenyuException; import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig; +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConversion; +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConverter; +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConverterFactory; +import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.ConnectResponsesToChatConverter; 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.model.ShenyuAiRequest; import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactory; +import org.apache.shenyu.plugin.ai.proxy.enhanced.routing.AiProtocolRouteResolver; import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiChat; import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses; import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProviderCapabilities; @@ -39,6 +44,7 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -54,7 +60,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolConverterFactory; +import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolRouteResolver; import static org.junit.jupiter.api.Assertions.assertTrue; class AiProxyEngineTest { @@ -80,7 +86,7 @@ void setUp() { engine = new AiProxyEngine( new AiProxyProtocolFactory(List.of(new OpenAiChat())), new AiProxyProviderFactory(List.of(new OpenAi())), - protocolConverterFactory(), + protocolRouteResolver(), transport); config = new AiCommonConfig(); config.setProvider("OpenAI"); @@ -219,7 +225,7 @@ void testFallbackProviderCapabilitiesCheckedBeforeBuildAndTransport() { final AtomicInteger transports = new AtomicInteger(); final AiProxyProvider fallback = customProvider(new AiProviderCapabilities(all.protocols(), all.operations(), Set.of()), builds); engine = new AiProxyEngine(new AiProxyProtocolFactory(List.of(new OpenAiChat())), - new AiProxyProviderFactory(List.of(new OpenAi(), fallback)), protocolConverterFactory(), request -> { + new AiProxyProviderFactory(List.of(new OpenAi(), fallback)), protocolRouteResolver(), request -> { transports.incrementAndGet(); return Mono.error(new AiTransportException(AiTransportException.Kind.IO_FAILURE, "unavailable", null)); }, Duration.ZERO); @@ -235,6 +241,57 @@ void testFallbackProviderCapabilitiesCheckedBeforeBuildAndTransport() { assertEquals(0, builds.get()); } + @Test + void testFallbackResolvesANewNativeProtocolRoute() { + final AtomicInteger conversions = new AtomicInteger(); + final ConnectResponsesToChatConverter delegate = new ConnectResponsesToChatConverter(); + final AiProtocolConverter converter = new AiProtocolConverter() { + @Override + public String sourceProtocol() { + return delegate.sourceProtocol(); + } + + @Override + public String targetProtocol() { + return delegate.targetProtocol(); + } + + @Override + public AiProtocolConversion convert(final ShenyuAiRequest request, final boolean stream) { + conversions.incrementAndGet(); + return delegate.convert(request, stream); + } + }; + final AiProtocolRouteResolver routeResolver = new AiProtocolRouteResolver( + new AiProtocolConverterFactory(List.of(converter))); + final List preparedProtocols = new ArrayList<>(); + final AiProxyProvider primary = recordingProvider( + "primary", AiProviderCapabilities.chat(), preparedProtocols); + final AiProviderCapabilities responses = new AiProviderCapabilities( + Set.of(OpenAiResponses.NAME), Set.of(ShenyuAiRequest.Operation.RESPONSE), Set.of()); + final AiProxyProvider fallback = recordingProvider("fallback", responses, preparedProtocols); + final AtomicInteger transports = new AtomicInteger(); + engine = new AiProxyEngine( + new AiProxyProtocolFactory(List.of(new OpenAiResponses())), + new AiProxyProviderFactory(List.of(primary, fallback)), + routeResolver, + request -> transports.incrementAndGet() == 1 + ? Mono.error(new AiTransportException(AiTransportException.Kind.IO_FAILURE, "unavailable", null)) + : Mono.just(upstreamResponse), + new AiProxyRetryProperties(0, 0, Duration.ZERO)); + config.setProvider("primary"); + config.setProtocol(OpenAiResponses.NAME); + final AiCommonConfig fallbackConfig = new AiCommonConfig(config); + fallbackConfig.setProvider("fallback"); + + StepVerifier.create(engine.execute("{\"model\":\"test\",\"input\":\"hello\"}", + config, Optional.of(fallbackConfig), response -> response.getBody().then())).verifyComplete(); + + assertEquals(List.of(OpenAiChat.NAME, OpenAiResponses.NAME), preparedProtocols); + assertEquals(1, conversions.get()); + assertEquals(2, transports.get()); + } + private void assertCapabilityRejected(final String body, final AiProviderCapabilities capabilities, final String capability) { final AtomicInteger builds = new AtomicInteger(); useCustomProvider(capabilities, builds); @@ -249,7 +306,7 @@ private void assertCapabilityRejected(final String body, final AiProviderCapabil private void useCustomProvider(final AiProviderCapabilities capabilities, final AtomicInteger builds) { engine = new AiProxyEngine(new AiProxyProtocolFactory(List.of(new OpenAiChat(), new OpenAiResponses())), - new AiProxyProviderFactory(List.of(customProvider(capabilities, builds))), protocolConverterFactory(), request -> { + new AiProxyProviderFactory(List.of(customProvider(capabilities, builds))), protocolRouteResolver(), request -> { transportedRequest.set(request); return Mono.just(upstreamResponse); }); @@ -275,4 +332,25 @@ public AiUpstreamRequest buildRequest(final ShenyuAiRequest request, final AiCom } }; } + + private AiProxyProvider recordingProvider(final String name, final AiProviderCapabilities capabilities, + final List preparedProtocols) { + return new AiProxyProvider() { + @Override + public String name() { + return name; + } + + @Override + public AiProviderCapabilities capabilities() { + return capabilities; + } + + @Override + public AiUpstreamRequest buildRequest(final ShenyuAiRequest request, final AiCommonConfig providerConfig) { + preparedProtocols.add(request.getProtocol()); + return new AiUpstreamRequest(); + } + }; + } } diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyStreamCancellationTest.java b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyStreamCancellationTest.java index bb6497f58f36..08ff11c23958 100644 --- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyStreamCancellationTest.java +++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyStreamCancellationTest.java @@ -43,7 +43,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolConverterFactory; +import static org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolRouteResolver; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -111,7 +111,7 @@ private AiProxyEngine engine(final Flux events) { WebClient client = WebClient.builder().exchangeFunction(request -> Mono.just(ClientResponse.create(HttpStatus.OK) .body(events.map(event -> DefaultDataBufferFactory.sharedInstance.wrap(event.getBytes(StandardCharsets.UTF_8)))).build())).build(); return new AiProxyEngine(new AiProxyProtocolFactory(List.of(new OpenAiChat())), new AiProxyProviderFactory(List.of(new OpenAi())), - protocolConverterFactory(), new WebClientAiProxyTransport(client), Duration.ZERO); + protocolRouteResolver(), new WebClientAiProxyTransport(client), Duration.ZERO); } private Mono stream(final AiProxyEngine engine, final AtomicInteger received) { 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 2500ceb6da5a..1cecf838fab2 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 @@ -25,6 +25,7 @@ import org.apache.shenyu.plugin.ai.proxy.enhanced.handler.AiProxyPluginHandler; import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocol; import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactory; +import org.apache.shenyu.plugin.ai.proxy.enhanced.routing.AiProtocolRouteResolver; import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiChat; import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses; import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProvider; @@ -110,7 +111,7 @@ public AiProxyApiKeyDataSubscriber aiProxyApiKeyDataSubscriber() { * * @param aiProxyProtocolFactory protocol factory * @param aiProxyProviderFactory provider factory - * @param aiProtocolConverterFactory protocol converter factory + * @param aiProtocolRouteResolver protocol route resolver * @param aiProxyTransport transport * @param retryProperties retry properties * @return the AI proxy engine @@ -118,11 +119,11 @@ public AiProxyApiKeyDataSubscriber aiProxyApiKeyDataSubscriber() { @Bean public AiProxyEngine aiProxyEngine(final AiProxyProtocolFactory aiProxyProtocolFactory, final AiProxyProviderFactory aiProxyProviderFactory, - final AiProtocolConverterFactory aiProtocolConverterFactory, + final AiProtocolRouteResolver aiProtocolRouteResolver, final AiProxyTransport aiProxyTransport, final AiProxyRetryProperties retryProperties) { return new AiProxyEngine(aiProxyProtocolFactory, aiProxyProviderFactory, - aiProtocolConverterFactory, aiProxyTransport, retryProperties); + aiProtocolRouteResolver, aiProxyTransport, retryProperties); } /** @@ -148,6 +149,18 @@ public AiProtocolConverterFactory aiProtocolConverterFactory(final List { org.assertj.core.api.Assertions.assertThat(context).hasNotFailed().hasSingleBean(AiProxyPlugin.class).hasSingleBean(AiProxyEngine.class) .hasSingleBean(AiProtocolConverterFactory.class).hasSingleBean(ConnectResponsesToChatConverter.class) + .hasSingleBean(AiProtocolRouteResolver.class) .doesNotHaveBean(AiModelFactoryRegistry.class); AiProxyProviderFactory factory = context.getBean(AiProxyProviderFactory.class); org.junit.jupiter.api.Assertions.assertTrue(context.getBean(AiProtocolConverterFactory.class)