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 819b92a63a refactor(ai-proxy): introduce protocol conversion
abstraction (#7910)
819b92a63a is described below
commit 819b92a63ad514b0508382f657a17def4b1dd9d3
Author: hengyuss <[email protected]>
AuthorDate: Thu Oct 22 09:58:41 2026 +0800
refactor(ai-proxy): introduce protocol conversion abstraction (#7910)
---
.../enhanced/conversion/AiProtocolConversion.java | 51 ++++
.../enhanced/conversion/AiProtocolConverter.java | 49 ++++
.../conversion/AiProtocolConverterFactory.java | 132 +++++++++++
.../ConnectResponsesToChatConverter.java} | 117 ++++++----
.../conversion/IdentityAiProtocolConverter.java | 63 +++++
.../enhanced/provider/AiProviderCapabilities.java | 9 +-
.../proxy/enhanced/provider/AiProxyProvider.java | 15 +-
.../provider/ConnectProviderDialectAdapter.java | 95 ++++++++
.../plugin/ai/proxy/enhanced/provider/OpenAi.java | 24 +-
.../ai/proxy/enhanced/service/AiProxyEngine.java | 67 ++++--
.../enhanced/AiProxyHttpCompatibilityTest.java | 3 +-
.../ai/proxy/enhanced/AiProxyPluginTest.java | 3 +-
.../plugin/ai/proxy/enhanced/AiProxyTestUtils.java | 41 ++++
.../conversion/AiProtocolConverterFactoryTest.java | 147 ++++++++++++
.../ConnectResponsesToChatConverterTest.java | 227 ++++++++++++++++++
.../protocol/ConnectProtocolAdapterTest.java | 258 ---------------------
.../enhanced/provider/AiProxyProviderHttpTest.java | 4 +-
.../ConnectProviderDialectAdapterTest.java | 86 +++++++
.../provider/OpenAiProviderOptionsTest.java | 20 +-
.../service/AiProxyEngineOrchestrationTest.java | 21 +-
.../proxy/enhanced/service/AiProxyEngineTest.java | 26 ++-
.../service/AiProxyStreamCancellationTest.java | 3 +-
.../ai/proxy/AiProxyPluginConfiguration.java | 31 ++-
.../ai/proxy/AiProxyPluginConfigurationTest.java | 9 +-
24 files changed, 1105 insertions(+), 396 deletions(-)
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConversion.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConversion.java
new file mode 100644
index 0000000000..6f59854172
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConversion.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.conversion;
+
+import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest;
+
+import java.util.Objects;
+
+/**
+ * Request-scoped protocol conversion binding a target request to its reverse
response adapter.
+ *
+ * @param request converted target protocol request
+ * @param responseAdapter reverse response adapter for this conversion
+ */
+public record AiProtocolConversion(
+ ShenyuAiRequest request,
+ AiResponseAdapter responseAdapter) {
+
+ /**
+ * Reject incomplete conversion results.
+ */
+ public AiProtocolConversion {
+ Objects.requireNonNull(request, "Converted AI request is required");
+ Objects.requireNonNull(responseAdapter, "AI response adapter is
required");
+ }
+
+ /**
+ * Create a conversion that leaves both request and response unchanged.
+ *
+ * @param request request to retain
+ * @return identity protocol conversion
+ */
+ public static AiProtocolConversion identity(final ShenyuAiRequest request)
{
+ return new AiProtocolConversion(request, AiResponseAdapter.identity());
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverter.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverter.java
new file mode 100644
index 0000000000..32d2965cf4
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverter.java
@@ -0,0 +1,49 @@
+/*
+ * 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.ShenyuAiRequest;
+
+/**
+ * Converts a client protocol request to a target protocol and binds reverse
response conversion.
+ */
+public interface AiProtocolConverter {
+
+ /**
+ * Get the source client protocol identifier.
+ *
+ * @return source protocol identifier
+ */
+ String sourceProtocol();
+
+ /**
+ * Get the target upstream protocol identifier.
+ *
+ * @return target protocol identifier
+ */
+ String targetProtocol();
+
+ /**
+ * Create one request-scoped bidirectional conversion.
+ *
+ * @param request source protocol request
+ * @param stream effective streaming mode
+ * @return converted request and its reverse response adapter
+ */
+ AiProtocolConversion convert(ShenyuAiRequest request, boolean stream);
+}
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
new file mode 100644
index 0000000000..53646589ea
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverterFactory.java
@@ -0,0 +1,132 @@
+/*
+ * 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.common.exception.ShenyuException;
+
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Registry for directional AI protocol converters.
+ */
+public final class AiProtocolConverterFactory {
+
+ private final Map<ConversionKey, AiProtocolConverter> converters;
+
+ /**
+ * Create a converter registry and reject duplicate directional routes.
+ *
+ * @param converters protocol converters
+ */
+ public AiProtocolConverterFactory(final Collection<AiProtocolConverter>
converters) {
+ this.converters = new LinkedHashMap<>();
+ for (AiProtocolConverter converter : converters) {
+ Objects.requireNonNull(converter, "AI protocol converter is
required");
+ ConversionKey key = new ConversionKey(converter.sourceProtocol(),
converter.targetProtocol());
+ AiProtocolConverter previous = this.converters.putIfAbsent(key,
converter);
+ if (Objects.nonNull(previous)) {
+ throw new IllegalArgumentException("Duplicate AI protocol
converter: "
+ + key.sourceProtocol() + " -> " +
key.targetProtocol());
+ }
+ }
+ }
+
+ /**
+ * Get a converter for an exact directional route.
+ *
+ * @param sourceProtocol source protocol identifier
+ * @param targetProtocol target protocol identifier
+ * @return resolved protocol converter
+ */
+ 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: "
+ + sourceProtocol + " -> " + targetProtocol);
+ }
+ return converter;
+ }
+
+ /**
+ * 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.
+ *
+ * @param sourceProtocol client protocol identifier
+ * @param targetProtocols protocols natively accepted by the provider
+ * @return identity or directional protocol converter
+ */
+ public AiProtocolConverter resolveConverter(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()) {
+ throw new IllegalArgumentException("Source AI protocol must not be
blank");
+ }
+ for (String targetProtocol : targetProtocols) {
+ Objects.requireNonNull(targetProtocol, "Target AI protocol is
required");
+ if (targetProtocol.isBlank()) {
+ 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();
+ }
+ }
+ throw new ShenyuException("Unsupported AI protocol conversion from " +
sourceProtocol
+ + " to provider protocols " + targetProtocols);
+ }
+
+ /**
+ * Determine whether an identity or registered directional route is
supported.
+ *
+ * @param sourceProtocol source protocol identifier
+ * @param targetProtocol target protocol identifier
+ * @return whether the route is supported
+ */
+ public boolean supports(final String sourceProtocol, final String
targetProtocol) {
+ if (Objects.isNull(sourceProtocol) || Objects.isNull(targetProtocol)
+ || sourceProtocol.isBlank() || targetProtocol.isBlank()) {
+ return false;
+ }
+ return sourceProtocol.equals(targetProtocol)
+ || converters.containsKey(new ConversionKey(sourceProtocol,
targetProtocol));
+ }
+
+ private record ConversionKey(String sourceProtocol, String targetProtocol)
{
+
+ private ConversionKey {
+ Objects.requireNonNull(sourceProtocol, "Source AI protocol is
required");
+ Objects.requireNonNull(targetProtocol, "Target AI protocol is
required");
+ if (sourceProtocol.isBlank() || targetProtocol.isBlank()) {
+ throw new IllegalArgumentException("AI protocol identifiers
must not be blank");
+ }
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/ConnectProtocolAdapter.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/ConnectResponsesToChatConverter.java
similarity index 63%
rename from
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/ConnectProtocolAdapter.java
rename to
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/ConnectResponsesToChatConverter.java
index ef257f6999..ea6409b29d 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/ConnectProtocolAdapter.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/ConnectResponsesToChatConverter.java
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-package org.apache.shenyu.plugin.ai.proxy.enhanced.protocol;
+package org.apache.shenyu.plugin.ai.proxy.enhanced.conversion;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
@@ -29,11 +29,13 @@ import io.github.agenticai.connect.Diagnostic;
import io.github.agenticai.connect.Format;
import io.github.agenticai.connect.Json;
import io.github.agenticai.connect.Options;
-import io.github.agenticai.connect.ProviderProfile;
import io.github.agenticai.connect.ResponseStreamState;
import io.github.agenticai.connect.StreamOptions;
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.model.ShenyuAiRequest;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiChat;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
@@ -46,12 +48,11 @@ import java.util.Map;
import java.util.Objects;
/**
- * Request-scoped bridge between ShenYu's reactive HTTP boundary and Connect's
JSON codecs.
- * No transport, routing, credentials, retry or billing policy is delegated to
Connect.
+ * Converts OpenAI Responses requests to Chat Completions and binds the
reverse response conversion.
*/
-public final class ConnectProtocolAdapter {
+public final class ConnectResponsesToChatConverter implements
AiProtocolConverter {
- private static final Logger LOG =
LoggerFactory.getLogger(ConnectProtocolAdapter.class);
+ private static final Logger LOG =
LoggerFactory.getLogger(ConnectResponsesToChatConverter.class);
private static final ObjectMapper MAPPER = new
ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
@@ -59,90 +60,101 @@ public final class ConnectProtocolAdapter {
private static final int MAX_CONVERSION_BYTES = 5 * 1024 * 1024;
- private final Format clientFormat;
+ @Override
+ public String sourceProtocol() {
+ return OpenAiResponses.NAME;
+ }
+
+ @Override
+ public String targetProtocol() {
+ return OpenAiChat.NAME;
+ }
- private final ConversionContext context;
+ @Override
+ public AiProtocolConversion convert(final ShenyuAiRequest request, final
boolean stream) {
+ Objects.requireNonNull(request, "AI request is required");
+ if (!sourceProtocol().equals(request.getProtocol())) {
+ throw new AiProxyException(AiProxyException.Kind.REQUEST,
+ "Expected OpenAI Responses request", null);
+ }
- /**
- * Create an isolated context for a request and its response, never a
singleton context.
- *
- * @param protocol client protocol identifier
- * @param upstreamModel authoritative routed model, or null to retain the
client model
- * @param profile explicitly selected provider dialect, or null for
generic Chat
- */
- public ConnectProtocolAdapter(final String protocol, final String
upstreamModel, final ProviderProfile profile) {
- clientFormat = OpenAiResponses.NAME.equals(protocol) ?
Format.OPENAI_RESPONSES : Format.OPENAI_CHAT;
+ ConversionContext context = createContext(request);
+ ObjectNode convertedPayload = convertRequest(request.getRawPayload(),
context);
+ ShenyuAiRequest convertedRequest = new OpenAiChat().decodeRequest(
+ convertedPayload.toString(), convertedPayload);
+ AiResponseAdapter responseAdapter = response ->
convertResponse(response, stream, context);
+ return new AiProtocolConversion(convertedRequest, responseAdapter);
+ }
+
+ private ConversionContext createContext(final ShenyuAiRequest request) {
Options options = new Options();
options.toolLossPolicy = Options.LossPolicy.SAFE;
- options.providerProfile = profile;
- context = new ConversionContext(options);
- context.upstreamModel = Objects.isNull(upstreamModel) ? "" :
upstreamModel;
+ ConversionContext context = new ConversionContext(options);
+ context.upstreamModel = Objects.requireNonNullElse(request.getModel(),
"");
+ return context;
}
- /**
- * Convert the final gateway-configured payload to Chat and apply the
provider profile once.
- *
- * @param payload copied client payload with gateway overrides applied
- * @return converted payload
- */
- public ObjectNode convertRequest(final ObjectNode payload) {
+ private ObjectNode convertRequest(final ObjectNode payload, final
ConversionContext context) {
+ if (Objects.isNull(payload)) {
+ throw new AiProxyException(AiProxyException.Kind.REQUEST, "AI
request payload is required", null);
+ }
try {
- ConversionResult result = CONNECT.convertRequest(clientFormat,
Format.OPENAI_CHAT, Json.object(payload.toString()), context);
+ ConversionResult result =
CONNECT.convertRequest(Format.OPENAI_RESPONSES, Format.OPENAI_CHAT,
+ Json.object(payload.toString()), context);
reportDiagnostics(result.diagnostics());
return (ObjectNode) MAPPER.readTree(result.json());
} catch (Exception error) {
- if (error instanceof ConversionException) {
- reportDiagnostics(((ConversionException) error).diagnostics());
+ if (error instanceof ConversionException conversionException) {
+ reportDiagnostics(conversionException.diagnostics());
}
throw new AiProxyException(AiProxyException.Kind.REQUEST, "AI
request protocol conversion failed", error);
}
}
- /**
- * Convert successful Responses bodies; preserve Chat and upstream HTTP
errors unchanged.
- *
- * @param response upstream response with an unconsumed body
- * @param stream effective streaming mode
- * @return adapted response without subscribing or blocking
- */
- public AiUpstreamResponse convertResponse(final AiUpstreamResponse
response, final boolean stream) {
- if (clientFormat == Format.OPENAI_CHAT || response.getStatusCode() <
200 || response.getStatusCode() >= 300) {
+ private AiUpstreamResponse convertResponse(final AiUpstreamResponse
response, final boolean stream,
+ final ConversionContext
context) {
+ if (response.getStatusCode() < 200 || response.getStatusCode() >= 300)
{
return response;
}
if (stream) {
// Reuse ShenYu's incremental SSE framing and content-type
validation, not a library HTTP client.
AiUpstreamResponse framed = new
OpenAiChat().encodeResponse(response, true);
- return new AiUpstreamResponse(response.getStatusCode(),
responseHeaders(response, "text/event-stream"),
convertStream(framed.getBody()));
+ return new AiUpstreamResponse(response.getStatusCode(),
responseHeaders(response, "text/event-stream"),
+ convertStream(framed.getBody(), context));
}
Flux<byte[]> body =
response.getBody().reduceWith(ByteArrayOutputStream::new, (buffer, bytes) -> {
if (bytes.length > MAX_CONVERSION_BYTES - buffer.size()) {
- throw new AiProxyException(AiProxyException.Kind.PROTOCOL, "AI
response exceeds the conversion size limit", null);
+ throw new AiProxyException(AiProxyException.Kind.PROTOCOL,
+ "AI response exceeds the conversion size limit", null);
}
buffer.writeBytes(bytes);
return buffer;
- }).map(buffer ->
convertCompleteResponse(buffer.toString(StandardCharsets.UTF_8))).flux();
+ }).map(buffer ->
convertCompleteResponse(buffer.toString(StandardCharsets.UTF_8),
context)).flux();
return new AiUpstreamResponse(response.getStatusCode(),
responseHeaders(response, "application/json"), body);
}
- private byte[] convertCompleteResponse(final String body) {
+ private byte[] convertCompleteResponse(final String body, final
ConversionContext context) {
try {
JsonNode payload = MAPPER.readTree(body);
if (Objects.isNull(payload) || !payload.isObject()) {
throw new IllegalArgumentException("Expected an AI response
object");
}
- ConversionResult result =
CONNECT.convertResponse(Format.OPENAI_CHAT, clientFormat,
Json.object(payload.toString()), context);
+ ConversionResult result =
CONNECT.convertResponse(Format.OPENAI_CHAT, Format.OPENAI_RESPONSES,
+ Json.object(payload.toString()), context);
reportDiagnostics(result.diagnostics());
return result.json().getBytes(StandardCharsets.UTF_8);
} catch (Exception error) {
- throw new AiProxyException(AiProxyException.Kind.PROTOCOL, "AI
response protocol conversion failed", error);
+ throw new AiProxyException(AiProxyException.Kind.PROTOCOL,
+ "AI response protocol conversion failed", error);
}
}
- private Flux<byte[]> convertStream(final Flux<byte[]> framed) {
+ private Flux<byte[]> convertStream(final Flux<byte[]> framed, final
ConversionContext context) {
return Flux.defer(() -> {
StreamOptions options = new StreamOptions();
options.emitSequenceNumber = true;
- ResponseStreamState state =
CONNECT.newResponseStream(Format.OPENAI_CHAT, clientFormat, options, context);
+ ResponseStreamState state = CONNECT.newResponseStream(
+ Format.OPENAI_CHAT, Format.OPENAI_RESPONSES, options,
context);
int[] size = {0};
return framed.concatMapIterable(bytes -> {
// The framer produces exactly one validated event, retaining
multiline JSON.
@@ -152,7 +164,8 @@ public final class ConnectProtocolAdapter {
return List.<ConversionResult>of();
}
if (bytes.length > MAX_CONVERSION_BYTES - size[0]) {
- throw new AiProxyException(AiProxyException.Kind.PROTOCOL,
"AI stream exceeds the conversion size limit", null);
+ throw new AiProxyException(AiProxyException.Kind.PROTOCOL,
+ "AI stream exceeds the conversion size limit",
null);
}
size[0] += bytes.length;
return state.accept(payload);
@@ -162,7 +175,8 @@ public final class ConnectProtocolAdapter {
return Flux.fromIterable(terminal);
})).map(this::frameEvent)
.onErrorMap(error -> !(error instanceof AiProxyException),
- error -> new
AiProxyException(AiProxyException.Kind.PROTOCOL, "AI stream protocol conversion
failed", error));
+ error -> new
AiProxyException(AiProxyException.Kind.PROTOCOL,
+ "AI stream protocol conversion failed",
error));
});
}
@@ -173,8 +187,9 @@ public final class ConnectProtocolAdapter {
private Map<String, List<String>> responseHeaders(final AiUpstreamResponse
response, final String contentType) {
Map<String, List<String>> headers = new
LinkedHashMap<>(response.getHeaders());
- headers.keySet().removeIf(name ->
"Content-Length".equalsIgnoreCase(name) || "Content-Type".equalsIgnoreCase(name)
- || "ETag".equalsIgnoreCase(name) ||
"Content-MD5".equalsIgnoreCase(name) || "Digest".equalsIgnoreCase(name));
+ headers.keySet().removeIf(name ->
"Content-Length".equalsIgnoreCase(name)
+ || "Content-Type".equalsIgnoreCase(name) ||
"ETag".equalsIgnoreCase(name)
+ || "Content-MD5".equalsIgnoreCase(name) ||
"Digest".equalsIgnoreCase(name));
headers.put("Content-Type", List.of(contentType));
return headers;
}
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
new file mode 100644
index 0000000000..6ad971d2ed
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/IdentityAiProtocolConverter.java
@@ -0,0 +1,63 @@
+/*
+ * 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/provider/AiProviderCapabilities.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProviderCapabilities.java
index 20d3d1dfe0..8ef056a22d 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProviderCapabilities.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProviderCapabilities.java
@@ -21,7 +21,6 @@ import com.fasterxml.jackson.databind.JsonNode;
import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiProxyException;
import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest;
import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiChat;
-import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
import java.util.EnumSet;
import java.util.Objects;
@@ -30,7 +29,7 @@ import java.util.Set;
/**
* Declared provider capabilities, independent of HTTP execution and model
discovery.
*
- * @param protocols supported client protocols
+ * @param protocols protocols accepted by the provider-facing request builder
* @param operations supported AI operations
* @param features supported request features
*/
@@ -46,13 +45,13 @@ public record AiProviderCapabilities(Set<String> protocols,
Set<ShenyuAiRequest.
}
/**
- * General Chat upstream capabilities, including conversion from Responses
clients.
+ * General OpenAI Chat upstream capabilities.
*
* @return general capabilities
*/
public static AiProviderCapabilities chat() {
- return new AiProviderCapabilities(Set.of(OpenAiChat.NAME,
OpenAiResponses.NAME),
- EnumSet.of(ShenyuAiRequest.Operation.CHAT_COMPLETION,
ShenyuAiRequest.Operation.RESPONSE),
+ return new AiProviderCapabilities(Set.of(OpenAiChat.NAME),
+ EnumSet.of(ShenyuAiRequest.Operation.CHAT_COMPLETION),
EnumSet.of(Feature.STREAMING, Feature.TOOL_CALLING,
Feature.CUSTOM_TOOLS, Feature.JSON_MODE,
Feature.JSON_SCHEMA, Feature.STREAM_USAGE,
Feature.AUDIO_OUTPUT));
}
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProxyProvider.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProxyProvider.java
index 5bec34746f..2f9276fee5 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProxyProvider.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProxyProvider.java
@@ -20,7 +20,6 @@ package org.apache.shenyu.plugin.ai.proxy.enhanced.provider;
import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
import org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiResponseAdapter;
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.PreparedAiRequest;
import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest;
@@ -58,25 +57,15 @@ public interface AiProxyProvider {
}
/**
- * Check whether the provider supports a client protocol.
+ * Check whether the provider natively accepts an upstream protocol.
*
- * @param protocol client protocol identifier
+ * @param protocol upstream protocol identifier
* @return whether supported
*/
default boolean supports(final String protocol) {
return Objects.nonNull(protocol) &&
capabilities().protocols().contains(protocol);
}
- /**
- * Adapt provider-specific response details before protocol encoding.
- *
- * @param response raw upstream response
- * @return adapted response
- */
- default AiUpstreamResponse adaptResponse(final AiUpstreamResponse
response) {
- return response;
- }
-
/**
* Prepare an upstream request and the response conversion bound to it.
* Existing providers receive an identity response adapter by default.
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/ConnectProviderDialectAdapter.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/ConnectProviderDialectAdapter.java
new file mode 100644
index 0000000000..8783112196
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/ConnectProviderDialectAdapter.java
@@ -0,0 +1,95 @@
+/*
+ * 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.provider;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.github.agenticai.connect.Connect;
+import io.github.agenticai.connect.ConversionContext;
+import io.github.agenticai.connect.ConversionException;
+import io.github.agenticai.connect.ConversionResult;
+import io.github.agenticai.connect.Diagnostic;
+import io.github.agenticai.connect.Format;
+import io.github.agenticai.connect.Json;
+import io.github.agenticai.connect.Options;
+import io.github.agenticai.connect.ProviderProfile;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiProxyException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Applies a provider-specific dialect to an OpenAI Chat request.
+ */
+public final class ConnectProviderDialectAdapter {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(ConnectProviderDialectAdapter.class);
+
+ private static final ObjectMapper MAPPER = new
ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+
+ private static final Connect CONNECT = new Connect();
+
+ private final ConversionContext context;
+
+ /**
+ * Create a request-scoped provider dialect adapter.
+ *
+ * @param upstreamModel authoritative routed model, or null to retain the
request model
+ * @param profile explicitly selected provider dialect, or null for
generic Chat
+ */
+ public ConnectProviderDialectAdapter(final String upstreamModel, final
ProviderProfile profile) {
+ Options options = new Options();
+ options.toolLossPolicy = Options.LossPolicy.SAFE;
+ options.providerProfile = profile;
+ context = new ConversionContext(options);
+ context.upstreamModel = Objects.requireNonNullElse(upstreamModel, "");
+ }
+
+ /**
+ * Apply the provider dialect without changing the Chat protocol.
+ *
+ * @param payload standard OpenAI Chat payload
+ * @return provider-specific Chat payload
+ */
+ public ObjectNode adapt(final ObjectNode payload) {
+ Objects.requireNonNull(payload, "AI provider payload is required");
+ try {
+ ConversionResult result =
CONNECT.convertRequest(Format.OPENAI_CHAT, Format.OPENAI_CHAT,
+ Json.object(payload.toString()), context);
+ reportDiagnostics(result.diagnostics());
+ return (ObjectNode) MAPPER.readTree(result.json());
+ } catch (Exception error) {
+ if (error instanceof ConversionException conversionException) {
+ reportDiagnostics(conversionException.diagnostics());
+ }
+ throw new AiProxyException(AiProxyException.Kind.REQUEST,
+ "AI provider dialect adaptation failed", error);
+ }
+ }
+
+ private void reportDiagnostics(final List<Diagnostic> diagnostics) {
+ for (Diagnostic diagnostic : diagnostics) {
+ // Never log payloads, credentials, private reasoning or library
diagnostic messages.
+ LOG.warn("AI provider dialect diagnostic: code={}, severity={},
from={}, to={}",
+ diagnostic.code(), diagnostic.severity(),
diagnostic.from(), diagnostic.to());
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAi.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAi.java
index ac8e52cb2d..efa224af38 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAi.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAi.java
@@ -27,8 +27,6 @@ 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.PreparedAiRequest;
import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest;
-import
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.ConnectProtocolAdapter;
-import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
@@ -132,21 +130,17 @@ public final class OpenAi implements AiProxyProvider {
if (config.getMaxTokens() <= 0) {
throw new IllegalArgumentException("AI maxTokens must be
positive");
}
- if (OpenAiResponses.NAME.equals(request.getProtocol())) {
- payload.put("max_output_tokens", config.getMaxTokens());
- } else {
- String field = options.maxTokensField().isEmpty()
- ? (payload.hasNonNull("max_completion_tokens") ?
"max_completion_tokens" : "max_tokens") : options.maxTokensField();
- payload.remove("max_tokens");
- payload.remove("max_completion_tokens");
- payload.put(field, config.getMaxTokens());
- }
+ String field = options.maxTokensField().isEmpty()
+ ? (payload.hasNonNull("max_completion_tokens") ?
"max_completion_tokens" : "max_tokens") : options.maxTokensField();
+ payload.remove("max_tokens");
+ payload.remove("max_completion_tokens");
+ payload.put(field, config.getMaxTokens());
}
if (!request.hasStream() && Objects.nonNull(config.getStream())) {
payload.put("stream", stream);
}
- ConnectProtocolAdapter adapter = new
ConnectProtocolAdapter(request.getProtocol(), mappedModel,
options.connectProfile());
- ObjectNode converted = adapter.convertRequest(payload);
+ ConnectProviderDialectAdapter adapter = new
ConnectProviderDialectAdapter(mappedModel, options.connectProfile());
+ ObjectNode converted = adapter.adapt(payload);
JsonNode convertedStream = converted.get("stream");
if (Objects.nonNull(convertedStream) && !convertedStream.isNull() &&
!convertedStream.isBoolean()
|| converted.path("stream").asBoolean() != stream) {
@@ -162,9 +156,7 @@ public final class OpenAi implements AiProxyProvider {
upstream.setHeaders(headers(config, stream));
String body = request.getRawPayload().equals(converted) ?
request.getRawBody() : converted.toString();
upstream.setBody(body.getBytes(StandardCharsets.UTF_8));
- AiResponseAdapter responseAdapter =
- response -> adapter.convertResponse(response, stream);
- return new PreparedAiRequest(upstream, responseAdapter);
+ return new PreparedAiRequest(upstream, AiResponseAdapter.identity());
}
private Map<String, List<String>> headers(final AiCommonConfig config,
final boolean stream) {
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 401c2b2f80..2fdc3055e0 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,6 +24,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
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;
@@ -58,6 +61,8 @@ public final class AiProxyEngine {
private final AiProxyProviderFactory aiProxyProviderFactory;
+ private final AiProtocolConverterFactory aiProtocolConverterFactory;
+
private final AiProxyTransport aiProxyTransport;
private final AiProxyRetryProperties retryProperties;
@@ -65,25 +70,30 @@ public final class AiProxyEngine {
public AiProxyEngine(
final AiProxyProtocolFactory protocols,
final AiProxyProviderFactory providers,
+ final AiProtocolConverterFactory converters,
final AiProxyTransport transport) {
this(
protocols,
providers,
+ converters,
transport,
new AiProxyRetryProperties());
}
public AiProxyEngine(final AiProxyProtocolFactory protocols, final
AiProxyProviderFactory providers,
- final AiProxyTransport transport, final
AiProxyRetryProperties retryProperties) {
- this.aiProxyProtocolFactory = protocols;
- this.aiProxyProviderFactory = providers;
- this.aiProxyTransport = transport;
+ final AiProtocolConverterFactory converters, 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.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 AiProxyTransport transport, final Duration
retryBackoff) {
- this(protocols, providers, transport, new AiProxyRetryProperties(3, 1,
retryBackoff));
+ final AiProtocolConverterFactory converters, final
AiProxyTransport transport,
+ final Duration retryBackoff) {
+ this(protocols, providers, converters, transport, new
AiProxyRetryProperties(3, 1, retryBackoff));
}
/**
@@ -105,7 +115,7 @@ public final class AiProxyEngine {
AiCommonConfig copy = new AiCommonConfig(config);
copy.setStream(primary.stream());
// Fallback changes the upstream, never the already selected
client protocol.
- copy.setProtocol(primary.protocol().name());
+ copy.setProtocol(primary.clientProtocol());
return copy;
});
final int maxRetries =
retryProperties.maxRetries(primary.stream());
@@ -138,10 +148,8 @@ public final class AiProxyEngine {
}
return response.getBody().then(Mono.error(new
UpstreamStatusFailure(status)));
}
- AiUpstreamResponse providerAdapted =
prepared.provider().adaptResponse(response);
- AiUpstreamResponse requestAdapted =
prepared.responseAdapter().adapt(providerAdapted);
- AiUpstreamResponse encoded =
prepared.protocol().encodeResponse(requestAdapted, prepared.stream());
- return consumeResponse(encoded, consumer, emitted);
+ AiUpstreamResponse adapted =
prepared.responseAdapter().adapt(response);
+ return consumeResponse(adapted, consumer, emitted);
})
.onErrorResume(error -> {
final AiProxyRetryPolicy.Decision decision;
@@ -224,28 +232,45 @@ public final class AiProxyEngine {
if (!protocol.matches(payload)) {
throw new ShenyuException("Request body does not match AI
protocol: " + protocol.name());
}
- ShenyuAiRequest decoded = protocol.decodeRequest(upstreamBody,
payload);
+ ShenyuAiRequest decoded =
applyModelOverride(protocol.decodeRequest(upstreamBody, payload), protocol,
config);
AiProxyProvider provider = aiProxyProviderFactory.getProvider(
Objects.isNull(config.getProvider()) ||
config.getProvider().isBlank() ? "openai" : config.getProvider());
- if (!provider.supports(protocol.name())) {
- throw new ShenyuException("Unsupported AI protocol/provider
combination");
- }
boolean stream = decoded.hasStream() ? decoded.isStream() :
Boolean.TRUE.equals(config.getStream());
- provider.capabilities().validate(decoded, stream);
+ AiProtocolConverter converter =
aiProtocolConverterFactory.resolveConverter(
+ decoded.getProtocol(), provider.capabilities().protocols());
+ AiProtocolConversion conversion = converter.convert(decoded, stream);
+ ShenyuAiRequest providerRequest = conversion.request();
+ if (!provider.supports(providerRequest.getProtocol())) {
+ throw new ShenyuException("Unsupported AI upstream
protocol/provider combination: "
+ + providerRequest.getProtocol() + " -> " +
provider.name());
+ }
+ provider.capabilities().validate(providerRequest, stream);
try {
- PreparedAiRequest preparedRequest =
provider.prepareRequest(decoded, config);
- return new PreparedExchange(protocol, provider,
preparedRequest.request(),
- preparedRequest.responseAdapter(), stream);
+ 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);
} catch (IllegalArgumentException error) {
throw new AiProxyException(AiProxyException.Kind.PROVIDER,
"Invalid AI provider configuration", error);
}
}
+ private ShenyuAiRequest applyModelOverride(final ShenyuAiRequest request,
final AiProxyProtocol protocol,
+ final AiCommonConfig config) {
+ String configuredModel = config.getModel();
+ if (Objects.isNull(configuredModel) || configuredModel.isBlank()
+ || configuredModel.equals(request.getModel())) {
+ return request;
+ }
+ ObjectNode effectivePayload = request.getRawPayload().deepCopy();
+ effectivePayload.put("model", configuredModel);
+ return protocol.decodeRequest(effectivePayload.toString(),
effectivePayload);
+ }
+
private record PreparedExchange(
- AiProxyProtocol protocol,
- AiProxyProvider provider,
AiUpstreamRequest request,
AiResponseAdapter responseAdapter,
+ String clientProtocol,
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 475c2196f0..ba9cd6940e 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,6 +54,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
@@ -258,7 +259,7 @@ class AiProxyHttpCompatibilityTest {
private AiProxyPlugin plugin(final AiProxyPluginHandler handler) {
AiProxyEngine engine = new AiProxyEngine(new
AiProxyProtocolFactory(List.of(new OpenAiChat(), new OpenAiResponses())), new
AiProxyProviderFactory(List.of(new OpenAi())),
- new WebClientAiProxyTransport(WebClient.builder().build()),
Duration.ZERO);
+ protocolConverterFactory(), 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 4431de85c7..416839bf9e 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,6 +61,7 @@ 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.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolConverterFactory;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -279,7 +280,7 @@ class AiProxyPluginTest {
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"))), request -> {
+ new AiProxyProviderFactory(List.of(new OpenAi(), new
OpenAiCompatible("DeepSeek"))), protocolConverterFactory(), 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
new file mode 100644
index 0000000000..bba2f6be8f
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyTestUtils.java
@@ -0,0 +1,41 @@
+/*
+ * 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;
+
+import
org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.AiProtocolConverterFactory;
+import
org.apache.shenyu.plugin.ai.proxy.enhanced.conversion.ConnectResponsesToChatConverter;
+
+import java.util.List;
+
+/**
+ * Test utilities for AI proxy fixtures.
+ */
+public final class AiProxyTestUtils {
+
+ private AiProxyTestUtils() {
+ }
+
+ /**
+ * Creates the protocol converter factory used by the default Spring
configuration.
+ *
+ * @return protocol converter factory
+ */
+ public static AiProtocolConverterFactory protocolConverterFactory() {
+ return new AiProtocolConverterFactory(List.of(new
ConnectResponsesToChatConverter()));
+ }
+}
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
new file mode 100644
index 0000000000..3e4de4db2d
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/AiProtocolConverterFactoryTest.java
@@ -0,0 +1,147 @@
+/*
+ * 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.common.exception.ShenyuException;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Set;
+
+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 AiProtocolConverterFactoryTest {
+
+ @Test
+ void testFindsConverterByDirectionalRoute() {
+ AiProtocolConverter converter = new TestConverter("openai-responses",
"openai-chat");
+ AiProtocolConverterFactory factory = new
AiProtocolConverterFactory(List.of(converter));
+
+ assertSame(converter, factory.getConverter("openai-responses",
"openai-chat"));
+ assertThrows(ShenyuException.class,
+ () -> factory.getConverter("openai-chat", "openai-responses"));
+ }
+
+ @Test
+ void testRejectsDuplicateRoute() {
+ assertThrows(IllegalArgumentException.class, () -> new
AiProtocolConverterFactory(List.of(
+ new TestConverter("source", "target"),
+ new TestConverter("source", "target"))));
+ }
+
+ @Test
+ void testRejectsUnsupportedRoute() {
+ AiProtocolConverterFactory factory = new
AiProtocolConverterFactory(List.of());
+
+ assertThrows(ShenyuException.class, () ->
factory.getConverter("source", "target"));
+ }
+
+ @Test
+ void testSupportsIdentityWithoutRegistration() {
+ AiProtocolConverterFactory factory = new
AiProtocolConverterFactory(List.of());
+
+ assertTrue(factory.supports("openai-chat", "openai-chat"));
+ assertFalse(factory.supports("openai-responses", "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() {
+ 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"));
+
+ assertTrue(resolved instanceof IdentityAiProtocolConverter);
+ }
+
+ @Test
+ void testSelectsRegisteredRouteToAnyProviderProtocol() {
+ AiProtocolConverter first = new TestConverter("source", "target-b");
+ 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"));
+
+ assertSame(first, resolved);
+ }
+
+ @Test
+ void testRejectsSourceWithoutRouteToProviderProtocols() {
+ AiProtocolConverterFactory factory = new
AiProtocolConverterFactory(List.of(
+ new TestConverter("source", "other")));
+
+ assertThrows(ShenyuException.class,
+ () -> factory.resolveConverter("source", Set.of("target-a",
"target-b")));
+ }
+
+ @Test
+ void testRejectsInvalidProtocolIdentifiers() {
+ AiProtocolConverterFactory factory = new
AiProtocolConverterFactory(List.of());
+
+ assertFalse(factory.supports(null, null));
+ assertFalse(factory.supports("", ""));
+ assertThrows(NullPointerException.class, () ->
factory.getConverter(null, null));
+ assertThrows(IllegalArgumentException.class, () ->
factory.getConverter("", ""));
+ }
+
+ private static final class TestConverter implements AiProtocolConverter {
+
+ private final String source;
+
+ private final String target;
+
+ private TestConverter(final String source, final String target) {
+ this.source = source;
+ this.target = target;
+ }
+
+ @Override
+ public String sourceProtocol() {
+ return source;
+ }
+
+ @Override
+ public String targetProtocol() {
+ return target;
+ }
+
+ @Override
+ public AiProtocolConversion convert(final ShenyuAiRequest request,
final boolean stream) {
+ return AiProtocolConversion.identity(request);
+ }
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/ConnectResponsesToChatConverterTest.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/ConnectResponsesToChatConverterTest.java
new file mode 100644
index 0000000000..11134ba8b8
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/conversion/ConnectResponsesToChatConverterTest.java
@@ -0,0 +1,227 @@
+/*
+ * 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 com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+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.model.ShenyuAiRequest;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiChat;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+import reactor.test.StepVerifier;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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 ConnectResponsesToChatConverterTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final String RESPONSE =
"{\"id\":\"chatcmpl-test\",\"model\":\"upstream\",\"choices\":[{\"index\":0,"
+ +
"\"message\":{\"role\":\"assistant\",\"content\":\"hello\"},\"finish_reason\":\"stop\"}],"
+ +
"\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}";
+
+ private static final String CHUNK =
"{\"id\":\"chatcmpl-test\",\"model\":\"upstream\",\"choices\":[{\"index\":0,"
+ +
"\"delta\":{\"role\":\"assistant\",\"content\":\"hello\"},\"finish_reason\":null}]}";
+
+ @Test
+ void testConvertsResponsesRequestWithoutMutatingSource() throws Exception {
+ ShenyuAiRequest source =
request("{\"model\":\"test\",\"instructions\":\"be helpful\","
+ + "\"input\":\"hello\",\"max_output_tokens\":12}");
+ final ObjectNode before = source.getRawPayload().deepCopy();
+
+ AiProtocolConversion conversion = new
ConnectResponsesToChatConverter().convert(source, false);
+
+ assertEquals(OpenAiChat.NAME, conversion.request().getProtocol());
+ assertEquals("system",
conversion.request().getRawPayload().at("/messages/0/role").asText());
+ assertEquals("hello",
conversion.request().getRawPayload().at("/messages/1/content").asText());
+ assertEquals(12,
conversion.request().getRawPayload().get("max_completion_tokens").asInt());
+ assertEquals(before, source.getRawPayload());
+ }
+
+ @Test
+ void testUsesRequestContextForReverseResponseConversion() throws Exception
{
+ AiProtocolConversion conversion = new
ConnectResponsesToChatConverter().convert(request(
+
"{\"model\":\"test\",\"input\":\"hi\",\"tools\":[{\"type\":\"custom\",\"name\":\"execute\"}]}"),
false);
+ String mappedName =
conversion.request().getRawPayload().at("/tools/0/function/name").asText();
+ String body =
"{\"model\":\"test\",\"choices\":[{\"index\":0,\"finish_reason\":\"tool_calls\","
+ +
"\"message\":{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_one\",\"type\":\"function\","
+ + "\"function\":{\"name\":\"" + mappedName
+ +
"\",\"arguments\":\"{\\\"input\\\":\\\"print(1)\\\"}\"}}]}}]}";
+
+ AiUpstreamResponse converted =
conversion.responseAdapter().adapt(response(200, Flux.just(bytes(body))));
+ JsonNode result =
MAPPER.readTree(converted.getBody().single().block(Duration.ofSeconds(3)));
+
+ assertEquals("custom_tool_call", result.at("/output/0/type").asText());
+ assertEquals("execute", result.at("/output/0/name").asText());
+ assertEquals("print(1)", result.at("/output/0/input").asText());
+ }
+
+ @Test
+ void testConvertsCompleteResponseAndRepresentationHeaders() throws
Exception {
+ AiProtocolConversion conversion = new
ConnectResponsesToChatConverter().convert(
+ request("{\"model\":\"test\",\"input\":\"hello\"}"), false);
+ AiUpstreamResponse upstream = new AiUpstreamResponse(200,
+ Map.of("content-length", List.of("123"), "ETag",
List.of("old"),
+ "X-Request-ID", List.of("kept")),
Flux.just(bytes(RESPONSE)));
+
+ AiUpstreamResponse converted =
conversion.responseAdapter().adapt(upstream);
+ JsonNode result =
MAPPER.readTree(converted.getBody().single().block(Duration.ofSeconds(3)));
+
+ assertEquals(Map.of("Content-Type", List.of("application/json"),
+ "X-Request-ID", List.of("kept")), converted.getHeaders());
+ assertEquals("response", result.get("object").asText());
+ assertEquals("hello", result.at("/output/0/content/0/text").asText());
+ }
+
+ @Test
+ void testPreservesHttpErrorsWithoutConsumingBody() throws Exception {
+ AiProtocolConversion conversion = new
ConnectResponsesToChatConverter().convert(
+ request("{\"model\":\"test\",\"input\":\"hello\"}"), false);
+ AiUpstreamResponse upstream = response(400, Flux.error(new
AssertionError("Body must not be consumed")));
+
+ assertSame(upstream, conversion.responseAdapter().adapt(upstream));
+ }
+
+ @Test
+ void testIsolatesStreamingStateOnEachSubscription() throws Exception {
+ AiProtocolConversion conversion = new
ConnectResponsesToChatConverter().convert(
+
request("{\"model\":\"test\",\"input\":\"hello\",\"stream\":true}"), true);
+ AiUpstreamResponse upstream = response(200, Flux.just(bytes("data: " +
CHUNK + "\n\ndata: [DONE]\n\n")));
+ Flux<String> converted =
conversion.responseAdapter().adapt(upstream).getBody()
+ .map(bytes -> new String(bytes, StandardCharsets.UTF_8));
+
+ for (int attempt = 0; attempt < 2; attempt++) {
+ List<String> events =
converted.collectList().block(Duration.ofSeconds(3));
+ assertEquals(1, events.stream()
+ .filter(event -> event.startsWith("event:
response.completed\n")).count());
+ assertEquals(1, events.stream()
+ .filter(event -> event.startsWith("event:
response.output_text.delta\n")
+ && event.contains("hello")).count());
+ }
+ }
+
+ @Test
+ void testRejectsMalformedAndOversizedCompleteResponses() throws Exception {
+ AiProtocolConversion conversion = new
ConnectResponsesToChatConverter().convert(
+ request("{\"model\":\"test\",\"input\":\"hello\"}"), false);
+ for (String body : List.of("invalid", "[]", "", "null", RESPONSE + "
{}",
+ "x".repeat(5 * 1024 * 1024 + 1))) {
+
StepVerifier.create(conversion.responseAdapter().adapt(response(200,
Flux.just(bytes(body)))).getBody())
+ .expectErrorMatches(error -> error instanceof
AiProxyException
+ && ((AiProxyException) error).getKind() ==
AiProxyException.Kind.PROTOCOL)
+ .verify();
+ }
+ StepVerifier.create(conversion.responseAdapter().adapt(response(200,
Flux.empty())).getBody())
+ .expectError(AiProxyException.class)
+ .verify();
+ }
+
+ @Test
+ void testConvertsFragmentedMultilineStreamAndProducesOneTerminalEvent()
throws Exception {
+ String wire = ": heartbeat\r\ndata: {\r\ndata: " + CHUNK.substring(1)
+ "\r\n\r\n"
+ + "data:
{\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2}}\r\n\r\n"
+ + "data: [DONE]\r\n\r\n";
+ List<byte[]> fragments = new ArrayList<>();
+ for (byte value : bytes(wire)) {
+ fragments.add(new byte[] {value});
+ }
+
+ List<String> events =
stream(Flux.fromIterable(fragments)).collectList().block(Duration.ofSeconds(3));
+
+ assertTrue(events.stream().anyMatch(event -> event.startsWith("event:
response.output_text.delta\n")
+ && event.contains("hello")));
+ assertEquals(1, events.stream().filter(event ->
event.startsWith("event: response.completed\n")).count());
+ assertTrue(events.get(events.size() -
1).contains("\"input_tokens\":3"));
+ }
+
+ @Test
+ void testDoesNotCompleteAfterMalformedEventOrTransportFailure() throws
Exception {
+ for (Flux<byte[]> body : List.of(
+ Flux.just(bytes("data: " + CHUNK + "\n\ndata: invalid\n\n")),
+ Flux.just(bytes("data: " + CHUNK + "\n\n"))
+ .concatWith(Flux.error(new
IllegalStateException("socket closed"))))) {
+ List<String> events = new ArrayList<>();
+ StepVerifier.create(stream(body).doOnNext(events::add))
+ .thenConsumeWhile(event -> !event.startsWith("event:
response.completed\n"))
+ .expectError(AiProxyException.class)
+ .verify();
+ assertTrue(events.stream().noneMatch(event ->
event.startsWith("event: response.completed\n")));
+ }
+ }
+
+ @Test
+ void testCancelsUpstreamAtDone() throws Exception {
+ AtomicBoolean cancelled = new AtomicBoolean();
+
+ List<String> events = stream(Flux.just(bytes("data: [DONE]\n\n"))
+ .concatWith(Flux.never()).doOnCancel(() ->
cancelled.set(true)))
+ .collectList().block(Duration.ofSeconds(3));
+
+ assertTrue(cancelled.get());
+ assertEquals(1, events.stream().filter(event ->
event.startsWith("event: response.completed\n")).count());
+ }
+
+ @Test
+ void testRejectsWrongSourceProtocol() throws Exception {
+ ObjectNode payload =
object("{\"model\":\"test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}");
+ ShenyuAiRequest request = new
OpenAiChat().decodeRequest(payload.toString(), payload);
+
+ AiProxyException error = assertThrows(AiProxyException.class,
+ () -> new ConnectResponsesToChatConverter().convert(request,
false));
+
+ assertEquals(AiProxyException.Kind.REQUEST, error.getKind());
+ }
+
+ private ShenyuAiRequest request(final String body) throws Exception {
+ ObjectNode payload = object(body);
+ return new OpenAiResponses().decodeRequest(body, payload);
+ }
+
+ private ObjectNode object(final String value) throws Exception {
+ return (ObjectNode) MAPPER.readTree(value);
+ }
+
+ private AiUpstreamResponse response(final int status, final Flux<byte[]>
body) {
+ return new AiUpstreamResponse(status, Map.of(), body);
+ }
+
+ private Flux<String> stream(final Flux<byte[]> body) throws Exception {
+ AiProtocolConversion conversion = new
ConnectResponsesToChatConverter().convert(
+
request("{\"model\":\"test\",\"input\":\"hello\",\"stream\":true}"), true);
+ return conversion.responseAdapter().adapt(response(200,
body)).getBody()
+ .map(bytes -> new String(bytes, StandardCharsets.UTF_8));
+ }
+
+ private byte[] bytes(final String value) {
+ return value.getBytes(StandardCharsets.UTF_8);
+ }
+}
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/ConnectProtocolAdapterTest.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/ConnectProtocolAdapterTest.java
deleted file mode 100644
index dab3b60d1f..0000000000
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/ConnectProtocolAdapterTest.java
+++ /dev/null
@@ -1,258 +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.protocol;
-
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ObjectNode;
-import io.github.agenticai.connect.ProviderProfile;
-import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiProxyException;
-import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamResponse;
-import org.junit.jupiter.api.Test;
-import reactor.core.publisher.Flux;
-import reactor.test.StepVerifier;
-
-import java.nio.charset.StandardCharsets;
-import java.time.Duration;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicInteger;
-
-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;
-
-/**
- * Library contracts at ShenYu's JSON/reactive boundary, without live-provider
calls.
- */
-class ConnectProtocolAdapterTest {
-
- private static final ObjectMapper MAPPER = new ObjectMapper();
-
- private static final String RESPONSE =
"{\"id\":\"chatcmpl-test\",\"model\":\"upstream\",\"choices\":[{\"index\":0,"
- +
"\"message\":{\"role\":\"assistant\",\"content\":\"hello\"},\"finish_reason\":\"stop\"}],"
- +
"\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}";
-
- private static final String CHUNK =
"{\"id\":\"chatcmpl-test\",\"model\":\"upstream\",\"choices\":[{\"index\":0,"
- +
"\"delta\":{\"role\":\"assistant\",\"content\":\"hello\"},\"finish_reason\":null}]}";
-
- @Test
- void delegatesRequestConversionAndProviderAdaptationExactlyOnce() throws
Exception {
- AtomicInteger calls = new AtomicInteger();
- ProviderProfile profile = (payload, adaptation) -> {
- calls.incrementAndGet();
- assertEquals("routed", payload.get("model").getAsString());
- payload.addProperty("custom", true);
- adaptation.warning("custom_profile", "custom", "Test profile");
- };
- ObjectNode input = object("{\"model\":\"alias\",\"instructions\":\"be
helpful\",\"input\":\"hello\",\"max_output_tokens\":12}");
- ObjectNode before = input.deepCopy();
- ObjectNode result = new ConnectProtocolAdapter(OpenAiResponses.NAME,
"routed", profile).convertRequest(input);
- assertEquals(1, calls.get());
- assertEquals(before, input);
- assertEquals("system", result.at("/messages/0/role").asText());
- assertEquals("hello", result.at("/messages/1/content").asText());
- assertEquals(12, result.get("max_completion_tokens").asInt());
- assertTrue(result.get("custom").asBoolean());
- }
-
- @Test
- void keepsGenericIdentityFieldsAndRejectsUnrepresentableResponsesState()
throws Exception {
- ObjectNode input =
object("{\"messages\":[{\"role\":\"user\"}],\"vendor\":true,\"content\":null}");
- assertEquals(input, new ConnectProtocolAdapter(OpenAiChat.NAME, null,
null).convertRequest(input));
- ConnectProtocolAdapter adapter = adapter();
- for (String body :
List.of("{\"model\":\"x\",\"input\":\"hi\",\"previous_response_id\":\"resp_old\"}",
"{\"input\":\"hi\"}")) {
- AiProxyException error = assertThrows(AiProxyException.class, ()
-> adapter.convertRequest(object(body)));
- assertEquals(AiProxyException.Kind.REQUEST, error.getKind());
- }
- }
-
- @Test
- void adaptsDeepSeekTokensAndReasoningWithoutGuessingFromAModel() throws
Exception {
- ObjectNode input =
object("{\"model\":\"deepseek-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],"
- +
"\"max_completion_tokens\":20,\"max_tokens\":10,\"reasoning_effort\":\"medium\"}");
- assertEquals(input, new ConnectProtocolAdapter(OpenAiChat.NAME, null,
null).convertRequest(input));
- ObjectNode result = new ConnectProtocolAdapter(OpenAiChat.NAME, null,
ProviderProfile.deepSeekChat()).convertRequest(input);
- assertEquals(20, result.get("max_tokens").asInt());
- assertFalse(result.has("max_completion_tokens"));
- assertEquals("high", result.get("reasoning_effort").asText());
- assertEquals("enabled", result.at("/thinking/type").asText());
- input.put("tool_choice", "required");
- assertThrows(AiProxyException.class,
- () -> new ConnectProtocolAdapter(OpenAiChat.NAME, null,
ProviderProfile.deepSeekChat()).convertRequest(input));
- }
-
- @Test
- void convertsCompleteResponsesAndRemovesStaleRepresentationHeaders()
throws Exception {
- AiUpstreamResponse upstream = new AiUpstreamResponse(200,
Map.of("content-length", List.of("123"), "ETag", List.of("old"),
- "Content-MD5", List.of("old"), "Digest", List.of("old"),
"X-Request-ID", List.of("kept")),
- Flux.just(bytes(RESPONSE.substring(0, 20)),
bytes(RESPONSE.substring(20))));
- AiUpstreamResponse converted = adapter().convertResponse(upstream,
false);
- assertEquals(Map.of("Content-Type", List.of("application/json"),
"X-Request-ID", List.of("kept")), converted.getHeaders());
- JsonNode result =
MAPPER.readTree(converted.getBody().single().block(Duration.ofSeconds(3)));
- assertEquals("response", result.get("object").asText());
- assertEquals("hello", result.at("/output/0/content/0/text").asText());
- assertEquals(3, result.at("/usage/input_tokens").asInt());
- assertEquals(2, result.at("/usage/output_tokens").asInt());
- }
-
- @Test
- void restoresCustomToolsUsingTheOriginalRequestContext() throws Exception {
- ConnectProtocolAdapter adapter = adapter();
- ObjectNode converted =
adapter.convertRequest(object("{\"model\":\"x\",\"input\":\"hi\",\"tools\":[{\"type\":\"custom\",\"name\":\"execute\"}]}"));
- String name = converted.at("/tools/0/function/name").asText();
- String body =
"{\"model\":\"x\",\"choices\":[{\"index\":0,\"finish_reason\":\"tool_calls\",\"message\":{\"role\":\"assistant\","
- +
"\"tool_calls\":[{\"id\":\"call_one\",\"type\":\"function\",\"function\":{\"name\":\""
+ name + "\",\"arguments\":\"{\\\"input\\\":\\\"print(1)\\\"}\"}}]}}]}";
- JsonNode result =
MAPPER.readTree(adapter.convertResponse(response(200, Flux.just(bytes(body))),
false).getBody().single().block());
- assertEquals("custom_tool_call", result.at("/output/0/type").asText());
- assertEquals("execute", result.at("/output/0/name").asText());
- assertEquals("print(1)", result.at("/output/0/input").asText());
- JsonNode unrelated =
MAPPER.readTree(adapter().convertResponse(response(200,
Flux.just(bytes(body))), false).getBody().single().block());
- assertEquals("function_call", unrelated.at("/output/0/type").asText());
- }
-
- @Test
- void restoresFragmentedCustomToolStreamsWithoutCrossRequestState() throws
Exception {
- ConnectProtocolAdapter custom = adapter();
- ObjectNode request =
custom.convertRequest(object("{\"model\":\"x\",\"input\":\"hi\",\"tools\":[{\"type\":\"custom\",\"name\":\"execute\"}]}"));
- String name = request.at("/tools/0/function/name").asText();
- ObjectNode start =
object("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_one\",\"type\":\"function\",\"function\":{}}]}}]}");
- ObjectNode function = (ObjectNode)
start.at("/choices/0/delta/tool_calls/0/function");
- function.put("name", name);
- function.put("arguments", "{\"input\":\"print(");
- ObjectNode next = start.deepCopy();
- ObjectNode continuation = (ObjectNode)
next.at("/choices/0/delta/tool_calls/0");
- continuation.remove(List.of("id", "type"));
- ((ObjectNode) continuation.get("function")).remove("name");
- ((ObjectNode) continuation.get("function")).put("arguments", "1)\"}");
- Flux<byte[]> chunks = Flux.just(bytes("data: " + start + "\n\n"),
bytes("data: " + next + "\n\n"),
- bytes("data:
{\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n"));
- Flux<String> restored = custom.convertResponse(response(200, chunks),
true).getBody().map(bytes -> new String(bytes, StandardCharsets.UTF_8));
- Flux<String> isolated = adapter().convertResponse(response(200,
chunks), true).getBody().map(bytes -> new String(bytes,
StandardCharsets.UTF_8));
- List<List<String>> results =
reactor.core.publisher.Mono.zip(restored.collectList(), isolated.collectList(),
List::of).block(Duration.ofSeconds(3));
- JsonNode customResult = terminalResponse(results.get(0));
- JsonNode genericResult = terminalResponse(results.get(1));
- assertEquals("custom_tool_call",
customResult.at("/response/output/0/type").asText());
- assertEquals("print(1)",
customResult.at("/response/output/0/input").asText());
- assertEquals("function_call",
genericResult.at("/response/output/0/type").asText());
- assertEquals("{\"input\":\"print(1)\"}",
genericResult.at("/response/output/0/arguments").asText());
- }
-
- private JsonNode terminalResponse(final List<String> events) throws
Exception {
- String terminal = events.get(events.size() - 1);
- return MAPPER.readTree(terminal.substring(terminal.indexOf("data: ") +
6).trim());
- }
-
- @Test
- void preservesHttpErrorsAndChatResponsesWithoutConsumingThem() {
- for (int status : List.of(199, 400, 500)) {
- AiUpstreamResponse upstream = response(status, Flux.error(new
AssertionError("Body must not be consumed")));
- assertSame(upstream, adapter().convertResponse(upstream, false));
- assertSame(upstream, adapter().convertResponse(upstream, true));
- }
- AiUpstreamResponse upstream = response(200, Flux.empty());
- assertSame(upstream, new ConnectProtocolAdapter(OpenAiChat.NAME, null,
null).convertResponse(upstream, true));
- }
-
- @Test
- void rejectsMalformedEmptyTrailingAndOversizedCompleteResponses() {
- for (String body : List.of("invalid", "[]", "", "null", RESPONSE + "
{}", "x".repeat(5 * 1024 * 1024 + 1))) {
- StepVerifier.create(adapter().convertResponse(response(200,
Flux.just(bytes(body))), false).getBody())
- .expectErrorMatches(error -> error instanceof
AiProxyException && ((AiProxyException) error).getKind() ==
AiProxyException.Kind.PROTOCOL).verify();
- }
- StepVerifier.create(adapter().convertResponse(response(200,
Flux.empty()), false).getBody()).expectError(AiProxyException.class).verify();
- }
-
- @Test
- void convertsFragmentedMultilineSseAndProducesOneTerminalEvent() {
- String wire = ": heartbeat\r\ndata: {\r\ndata: " + CHUNK.substring(1)
+ "\r\n\r\n"
- + "data:
{\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2}}\r\n\r\ndata:
[DONE]\r\n\r\n";
- List<byte[]> fragments = new ArrayList<>();
- for (byte value : bytes(wire)) {
- fragments.add(new byte[] {value});
- }
- List<String> events =
stream(Flux.fromIterable(fragments)).collectList().block(Duration.ofSeconds(3));
- assertTrue(events.stream().anyMatch(event -> event.startsWith("event:
response.output_text.delta\n") && event.contains("hello")));
- assertEquals(1, events.stream().filter(event ->
event.startsWith("event: response.completed\n")).count());
- assertTrue(events.get(events.size() -
1).contains("\"input_tokens\":3"));
- assertTrue(events.stream().allMatch(event ->
event.contains("\"sequence_number\":") && !event.contains("[DONE]")));
- }
-
- @Test
- void isolatesStreamStateOnResubscriptionAndCancelsWithoutATerminalEvent() {
- Flux<String> reusable = stream(Flux.just(bytes("data: " + CHUNK +
"\n\n")));
- for (int attempt = 0; attempt < 2; attempt++) {
- List<String> events = reusable.collectList().block();
- assertEquals(1, events.stream().filter(event ->
event.startsWith("event: response.completed\n")).count());
- }
- AtomicBoolean cancelled = new AtomicBoolean();
- Flux<String> live = stream(Flux.just(bytes("data: " + CHUNK +
"\n\n")).concatWith(Flux.never()).doOnCancel(() -> cancelled.set(true)));
- StepVerifier.create(live, 0).thenRequest(1).expectNextMatches(event ->
event.startsWith("event: response.created\n"))
- .thenCancel().verify(Duration.ofSeconds(3));
- assertTrue(cancelled.get());
- }
-
- @Test
- void doesNotSynthesizeSuccessAfterMalformedEventsOrTransportFailure() {
- for (Flux<byte[]> body : List.of(Flux.just(bytes("data: " + CHUNK +
"\n\ndata: invalid\n\n")),
- Flux.just(bytes("data: " + CHUNK +
"\n\n")).concatWith(Flux.error(new IllegalStateException("socket closed"))))) {
- List<String> events = new ArrayList<>();
-
StepVerifier.create(stream(body).doOnNext(events::add)).thenConsumeWhile(event
-> !event.startsWith("event: response.completed\n"))
- .expectError(AiProxyException.class).verify();
- assertTrue(events.stream().noneMatch(event ->
event.startsWith("event: response.completed\n")));
- }
- AiUpstreamResponse json = new AiUpstreamResponse(200,
Map.of("Content-Type", List.of("application/json")),
Flux.just(bytes(RESPONSE)));
- StepVerifier.create(adapter().convertResponse(json,
true).getBody()).expectError(AiProxyException.class).verify();
- }
-
- @Test
- void boundsCumulativeConversionStateAndCancelsAtDone() {
- String large = "data: " + CHUNK.replace("hello", "x".repeat(1024 *
1024)) + "\n\n";
- StepVerifier.create(stream(Flux.range(0, 6).map(index ->
bytes(large))))
- .thenConsumeWhile(event -> !event.startsWith("event:
response.completed\n")).expectError(AiProxyException.class).verify();
- AtomicBoolean cancelled = new AtomicBoolean();
- List<String> events = stream(Flux.just(bytes("data:
[DONE]\n\n")).concatWith(Flux.never()).doOnCancel(() -> cancelled.set(true)))
- .collectList().block(Duration.ofSeconds(3));
- assertTrue(cancelled.get());
- assertEquals(1, events.stream().filter(event ->
event.startsWith("event: response.completed\n")).count());
- }
-
- private ConnectProtocolAdapter adapter() {
- return new ConnectProtocolAdapter(OpenAiResponses.NAME, null, null);
- }
-
- private ObjectNode object(final String json) throws Exception {
- return (ObjectNode) MAPPER.readTree(json);
- }
-
- private AiUpstreamResponse response(final int status, final Flux<byte[]>
body) {
- return new AiUpstreamResponse(status, Map.of(), body);
- }
-
- private Flux<String> stream(final Flux<byte[]> body) {
- return adapter().convertResponse(response(200, body),
true).getBody().map(bytes -> new String(bytes, StandardCharsets.UTF_8));
- }
-
- private byte[] bytes(final String value) {
- return value.getBytes(StandardCharsets.UTF_8);
- }
-}
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 7453e95ee6..092c9fc97c 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,6 +41,7 @@ import java.util.concurrent.atomic.AtomicReference;
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.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -129,7 +130,8 @@ class AiProxyProviderHttpTest {
private AiProxyEngine engine(final AiProxyProvider provider) {
return new AiProxyEngine(new AiProxyProtocolFactory(List.of(new
OpenAiChat(), new OpenAiResponses())),
- new AiProxyProviderFactory(List.of(provider)), new
WebClientAiProxyTransport(WebClient.builder().build()));
+ new AiProxyProviderFactory(List.of(provider)),
protocolConverterFactory(),
+ new WebClientAiProxyTransport(WebClient.builder().build()));
}
private AiCommonConfig config(final DisposableServer server, final String
provider, final String protocol) {
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/ConnectProviderDialectAdapterTest.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/ConnectProviderDialectAdapterTest.java
new file mode 100644
index 0000000000..cbfaab862f
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/ConnectProviderDialectAdapterTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.provider;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.github.agenticai.connect.ProviderProfile;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiProxyException;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests provider-specific Chat dialect adaptation independently of protocol
conversion.
+ */
+class ConnectProviderDialectAdapterTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ @Test
+ void appliesProviderProfileExactlyOnceWithoutMutatingSource() throws
Exception {
+ AtomicInteger calls = new AtomicInteger();
+ ProviderProfile profile = (payload, adaptation) -> {
+ calls.incrementAndGet();
+ assertEquals("routed", payload.get("model").getAsString());
+ payload.addProperty("custom", true);
+ adaptation.warning("custom_profile", "custom", "Test profile");
+ };
+ ObjectNode input =
object("{\"model\":\"alias\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}");
+ ObjectNode before = input.deepCopy();
+
+ ObjectNode result = new ConnectProviderDialectAdapter("routed",
profile).adapt(input);
+
+ assertEquals(1, calls.get());
+ assertEquals(before, input);
+ assertEquals("routed", result.path("model").asText());
+ assertTrue(result.path("custom").asBoolean());
+ }
+
+ @Test
+ void keepsGenericChatPayloadUnchanged() throws Exception {
+ ObjectNode input =
object("{\"messages\":[{\"role\":\"user\"}],\"vendor\":true,\"content\":null}");
+
+ assertEquals(input, new ConnectProviderDialectAdapter(null,
null).adapt(input));
+ }
+
+ @Test
+ void adaptsDeepSeekTokensAndReasoning() throws Exception {
+ ObjectNode input =
object("{\"model\":\"deepseek-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],"
+ +
"\"max_completion_tokens\":20,\"max_tokens\":10,\"reasoning_effort\":\"medium\"}");
+ ObjectNode result = new ConnectProviderDialectAdapter(null,
ProviderProfile.deepSeekChat()).adapt(input);
+
+ assertEquals(20, result.path("max_tokens").asInt());
+ assertFalse(result.has("max_completion_tokens"));
+ assertEquals("high", result.path("reasoning_effort").asText());
+ assertEquals("enabled", result.at("/thinking/type").asText());
+
+ input.put("tool_choice", "required");
+ assertThrows(AiProxyException.class,
+ () -> new ConnectProviderDialectAdapter(null,
ProviderProfile.deepSeekChat()).adapt(input));
+ }
+
+ private ObjectNode object(final String json) throws Exception {
+ return (ObjectNode) MAPPER.readTree(json);
+ }
+}
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 0800e25581..e8481a7876 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
@@ -21,6 +21,8 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
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.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;
@@ -45,6 +47,7 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
+import static
org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyTestUtils.protocolConverterFactory;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -191,7 +194,9 @@ class OpenAiProviderOptionsTest {
void compatibleTokenFieldChoiceRunsInConnectAfterResponsesConversion()
throws Exception {
OpenAiCompatible provider = new OpenAiCompatible("legacy",
OpenAiProviderOptions.generic(null).withMaxTokensField("max_tokens"));
config.setMaxTokens(17);
- AiUpstreamRequest upstream =
provider.buildRequest(responses("{\"model\":\"m\",\"input\":\"hi\",\"max_output_tokens\":7}"),
config);
+ AiProtocolConversion conversion = new
ConnectResponsesToChatConverter().convert(
+
responses("{\"model\":\"m\",\"input\":\"hi\",\"max_output_tokens\":7}"), false);
+ AiUpstreamRequest upstream =
provider.buildRequest(conversion.request(), config);
JsonNode payload = MAPPER.readTree(upstream.getBody());
assertEquals(17, payload.get("max_tokens").asInt());
assertFalse(payload.has("max_completion_tokens"));
@@ -237,8 +242,12 @@ class OpenAiProviderOptionsTest {
AiUpstreamResponse response = new AiUpstreamResponse(200,
Map.of("Content-Type", List.of("application/json")), Flux.just(bytes(reply)));
PreparedAiRequest chat = provider.prepareRequest(chat(CHAT), config);
assertEquals(reply,
text(chat.responseAdapter().adapt(response).getBody().single().block(Duration.ofSeconds(3))));
- PreparedAiRequest responses =
provider.prepareRequest(responses("{\"model\":\"deepseek-flash\",\"input\":\"hi\"}"),
config);
- JsonNode converted =
MAPPER.readTree(responses.responseAdapter().adapt(response).getBody().single().block(Duration.ofSeconds(3)));
+ AiProtocolConversion conversion = new
ConnectResponsesToChatConverter().convert(
+ responses("{\"model\":\"deepseek-flash\",\"input\":\"hi\"}"),
false);
+ PreparedAiRequest prepared =
provider.prepareRequest(conversion.request(), config);
+ AiUpstreamResponse providerAdapted =
prepared.responseAdapter().adapt(response);
+ JsonNode converted =
MAPPER.readTree(conversion.responseAdapter().adapt(providerAdapted)
+ .getBody().single().block(Duration.ofSeconds(3)));
assertEquals("response", converted.get("object").asText());
assertTrue(converted.toString().contains("think"));
assertTrue(converted.toString().contains("answer"));
@@ -248,12 +257,13 @@ class OpenAiProviderOptionsTest {
@Test
void unsupportedOperationsFailBeforeTransportWithoutEngineChanges() {
AiProviderCapabilities all = AiProviderCapabilities.chat();
- AiProviderCapabilities restricted = new
AiProviderCapabilities(all.protocols(),
Set.of(ShenyuAiRequest.Operation.CHAT_COMPLETION), all.features());
+ AiProviderCapabilities restricted = new AiProviderCapabilities(
+ all.protocols(), Set.of(ShenyuAiRequest.Operation.RESPONSE),
all.features());
OpenAiCompatible custom = new OpenAiCompatible("restricted",
options("/generate", OpenAiProviderOptions.Authentication.NONE, "", "",
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)), request -> {
+ new AiProxyProviderFactory(List.of(custom)),
protocolConverterFactory(), 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/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 fe77d269f7..9297d904dd 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,6 +57,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
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.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -417,7 +418,7 @@ class AiProxyEngineOrchestrationTest {
}
@Test
- void providerRequestAdapterAndProtocolConvertTheResponseInOrder() {
+ void providerResponseAdapterAndProtocolConvertTheResponseInOrder() {
List<String> conversions = new ArrayList<>();
AtomicBoolean supported = new AtomicBoolean(true);
AiProxyProvider provider = new AiProxyProvider() {
@@ -439,16 +440,10 @@ class AiProxyEngineOrchestrationTest {
@Override
public PreparedAiRequest prepareRequest(final ShenyuAiRequest
request, final AiCommonConfig config) {
return new PreparedAiRequest(buildRequest(request, config),
response -> {
- conversions.add("request");
+ conversions.add("provider");
return response;
});
}
-
- @Override
- public AiUpstreamResponse adaptResponse(final AiUpstreamResponse
response) {
- conversions.add("provider");
- return response;
- }
};
OpenAiChat delegate = new OpenAiChat();
AiProxyProtocol protocol = new AiProxyProtocol() {
@@ -476,9 +471,9 @@ class AiProxyEngineOrchestrationTest {
AiCommonConfig config = config(false, "model");
config.setProvider("custom");
AiProxyEngine engine = new AiProxyEngine(new
AiProxyProtocolFactory(List.of(protocol)), new
AiProxyProviderFactory(List.of(provider)),
- request -> Mono.just(response(200, Flux.just(bytes("ok")))),
Duration.ZERO);
+ protocolConverterFactory(), 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", "request", "protocol"), conversions);
+ assertEquals(List.of("provider", "protocol"), conversions);
supported.set(false);
conversions.clear();
StepVerifier.create(engine.execute(BODY, config, Optional.empty(),
response ->
response.getBody().then())).expectError(ShenyuException.class).verify();
@@ -504,12 +499,14 @@ class AiProxyEngineOrchestrationTest {
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"))), transport, Duration.ZERO);
+ new AiProxyProviderFactory(List.of(new OpenAi(), new
OpenAiCompatible("DeepSeek"))),
+ protocolConverterFactory(), 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"))), transport, retryProperties);
+ new AiProxyProviderFactory(List.of(new OpenAi(), new
OpenAiCompatible("DeepSeek"))),
+ protocolConverterFactory(), 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 cdcc7c502c..fc6817ba86 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
@@ -54,6 +54,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
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.junit.jupiter.api.Assertions.assertTrue;
class AiProxyEngineTest {
@@ -79,6 +80,7 @@ class AiProxyEngineTest {
engine = new AiProxyEngine(
new AiProxyProtocolFactory(List.of(new OpenAiChat())),
new AiProxyProviderFactory(List.of(new OpenAi())),
+ protocolConverterFactory(),
transport);
config = new AiCommonConfig();
config.setProvider("OpenAI");
@@ -153,25 +155,31 @@ class AiProxyEngineTest {
}
@Test
- void testCustomProviderProtocolRejectedBeforeBuildAndTransport() {
+ void testCustomChatProviderAcceptsResponsesThroughConversion() throws
Exception {
final AiProviderCapabilities all = AiProviderCapabilities.chat();
final AtomicInteger builds = new AtomicInteger();
useCustomProvider(new AiProviderCapabilities(Set.of(OpenAiChat.NAME),
all.operations(), all.features()), builds);
config.setProtocol(OpenAiResponses.NAME);
- StepVerifier.create(engine.execute("{\"input\":\"hello\"}", config,
Optional.empty(), response -> response.getBody().then()))
- .expectError(ShenyuException.class).verify();
+ AiUpstreamRequest request =
engine.buildRequest("{\"model\":\"test\",\"input\":\"hello\"}", config);
+ JsonNode payload = OBJECT_MAPPER.readTree(request.getBody());
- assertEquals(0, builds.get());
+ assertEquals(1, builds.get());
+ assertEquals("hello", payload.at("/messages/0/content").asText());
assertNull(transportedRequest.get());
}
@Test
- void testCustomProviderOperationRejectedBeforeBuildAndTransport() {
+ void testCustomChatProviderValidatesConvertedOperation() {
final AiProviderCapabilities all = AiProviderCapabilities.chat();
+ final AtomicInteger builds = new AtomicInteger();
+ useCustomProvider(new AiProviderCapabilities(all.protocols(),
+ Set.of(ShenyuAiRequest.Operation.CHAT_COMPLETION),
all.features()), builds);
config.setProtocol(OpenAiResponses.NAME);
- assertCapabilityRejected("{\"input\":\"hello\"}",
- new AiProviderCapabilities(all.protocols(),
Set.of(ShenyuAiRequest.Operation.CHAT_COMPLETION), all.features()),
"operation");
+
+ engine.buildRequest("{\"model\":\"test\",\"input\":\"hello\"}",
config);
+
+ assertEquals(1, builds.get());
}
@Test
@@ -211,7 +219,7 @@ class AiProxyEngineTest {
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)),
request -> {
+ new AiProxyProviderFactory(List.of(new OpenAi(), fallback)),
protocolConverterFactory(), request -> {
transports.incrementAndGet();
return Mono.error(new
AiTransportException(AiTransportException.Kind.IO_FAILURE, "unavailable",
null));
}, Duration.ZERO);
@@ -241,7 +249,7 @@ class AiProxyEngineTest {
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))), request
-> {
+ new
AiProxyProviderFactory(List.of(customProvider(capabilities, builds))),
protocolConverterFactory(), request -> {
transportedRequest.set(request);
return Mono.just(upstreamResponse);
});
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 bd6b3ce523..bb6497f58f 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,6 +43,7 @@ import java.util.concurrent.atomic.AtomicInteger;
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.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -110,7 +111,7 @@ class AiProxyStreamCancellationTest {
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())),
- new WebClientAiProxyTransport(client), Duration.ZERO);
+ protocolConverterFactory(), new
WebClientAiProxyTransport(client), Duration.ZERO);
}
private Mono<Void> 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 d84e90d965..2500ceb6da 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
@@ -19,6 +19,9 @@ 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.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.handler.AiProxyPluginHandler;
import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocol;
import
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactory;
@@ -107,6 +110,7 @@ public class AiProxyPluginConfiguration {
*
* @param aiProxyProtocolFactory protocol factory
* @param aiProxyProviderFactory provider factory
+ * @param aiProtocolConverterFactory protocol converter factory
* @param aiProxyTransport transport
* @param retryProperties retry properties
* @return the AI proxy engine
@@ -114,9 +118,34 @@ public class AiProxyPluginConfiguration {
@Bean
public AiProxyEngine aiProxyEngine(final AiProxyProtocolFactory
aiProxyProtocolFactory,
final AiProxyProviderFactory
aiProxyProviderFactory,
+ final AiProtocolConverterFactory
aiProtocolConverterFactory,
final AiProxyTransport aiProxyTransport,
final AiProxyRetryProperties
retryProperties) {
- return new AiProxyEngine(aiProxyProtocolFactory,
aiProxyProviderFactory, aiProxyTransport, retryProperties);
+ return new AiProxyEngine(aiProxyProtocolFactory,
aiProxyProviderFactory,
+ aiProtocolConverterFactory, aiProxyTransport, retryProperties);
+ }
+
+ /**
+ * Responses-to-Chat protocol converter.
+ *
+ * @return the Responses-to-Chat protocol converter
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ public ConnectResponsesToChatConverter connectResponsesToChatConverter() {
+ return new ConnectResponsesToChatConverter();
+ }
+
+ /**
+ * AI protocol converter factory.
+ *
+ * @param converters protocol converters
+ * @return the AI protocol converter factory
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ public AiProtocolConverterFactory aiProtocolConverterFactory(final
List<AiProtocolConverter> converters) {
+ return new AiProtocolConverterFactory(converters);
}
/**
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 271edb3cf6..618b7aa5b4 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
@@ -28,7 +28,10 @@ import
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProvider;
import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.OpenAiCompatible;
import
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.OpenAiProviderOptions;
import org.apache.shenyu.plugin.ai.proxy.enhanced.AiProxyPlugin;
+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.protocol.AiProxyProtocolFactory;
+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.AiProxyProviderFactory;
import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyEngine;
@@ -61,12 +64,16 @@ class AiProxyPluginConfigurationTest {
void createsTheMigratedPluginAndAllExistingProviderIdentifiers() {
new
ApplicationContextRunner().withUserConfiguration(AiProxyPluginConfiguration.class).run(context
-> {
org.assertj.core.api.Assertions.assertThat(context).hasNotFailed().hasSingleBean(AiProxyPlugin.class).hasSingleBean(AiProxyEngine.class)
+
.hasSingleBean(AiProtocolConverterFactory.class).hasSingleBean(ConnectResponsesToChatConverter.class)
.doesNotHaveBean(AiModelFactoryRegistry.class);
AiProxyProviderFactory factory =
context.getBean(AiProxyProviderFactory.class);
+
org.junit.jupiter.api.Assertions.assertTrue(context.getBean(AiProtocolConverterFactory.class)
+ .supports(OpenAiResponses.NAME, OpenAiChat.NAME));
assertSame(context.getBean(OpenAiResponses.class),
context.getBean(AiProxyProtocolFactory.class).getProtocol(OpenAiResponses.NAME));
for (String provider : List.of("OpenAI", "DeepSeek", "ALiYun",
"OpenAPI", "Moonshot", "OpenAICompatible")) {
assertEquals(provider.toLowerCase(java.util.Locale.ROOT),
factory.getProvider(provider).name().toLowerCase(java.util.Locale.ROOT));
-
org.junit.jupiter.api.Assertions.assertTrue(factory.getProvider(provider).supports(OpenAiResponses.NAME));
+
org.junit.jupiter.api.Assertions.assertTrue(factory.getProvider(provider).supports(OpenAiChat.NAME));
+
org.junit.jupiter.api.Assertions.assertFalse(factory.getProvider(provider).supports(OpenAiResponses.NAME));
}
});
}