Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -58,9 +59,6 @@ public AiProtocolConverterFactory(final Collection<AiProtocolConverter> 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: "
Expand All @@ -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<String> targetProtocols) {
public Optional<AiProtocolConverter> findConverter(final String sourceProtocol,
final Collection<String> targetProtocols) {
Objects.requireNonNull(sourceProtocol, "Source AI protocol is required");
Objects.requireNonNull(targetProtocols, "Target AI protocols are required");
if (sourceProtocol.isBlank()) {
Expand All @@ -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<ConversionKey, AiProtocolConverter> 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
Expand All @@ -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) {
Expand All @@ -129,4 +123,5 @@ private record ConversionKey(String sourceProtocol, String targetProtocol) {
}
}
}

}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -61,7 +60,7 @@ public final class AiProxyEngine {

private final AiProxyProviderFactory aiProxyProviderFactory;

private final AiProtocolConverterFactory aiProtocolConverterFactory;
private final AiProtocolRouteResolver aiProtocolRouteResolver;

private final AiProxyTransport aiProxyTransport;

Expand All @@ -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));
}

/**
Expand Down Expand Up @@ -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());
Expand All @@ -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);
}
Expand All @@ -271,6 +271,7 @@ private record PreparedExchange(
AiUpstreamRequest request,
AiResponseAdapter responseAdapter,
String clientProtocol,
String targetProtocol,
boolean stream) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading