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 1d599d90e8 [ISSUE #7383] Harden provider profiles and capability
validation. (#7580)
1d599d90e8 is described below
commit 1d599d90e82e3415720b50885823eadc7b93d431
Author: redyyang <[email protected]>
AuthorDate: Thu Oct 15 18:58:23 2026 +0800
[ISSUE #7383] Harden provider profiles and capability validation. (#7580)
* refactor(ai-proxy): harden provider profiles and capability validation.
* fix(ai-proxy): enforce declared provider capabilities before adaptation
---------
Co-authored-by: redyyang <[email protected]>
---
.../shenyu-plugin-ai-proxy/README.md | 84 ++++-
.../ai/proxy/enhanced/model/ShenyuAiRequest.java | 27 ++
.../ai/proxy/enhanced/protocol/OpenAiChat.java | 1 +
.../proxy/enhanced/protocol/OpenAiResponses.java | 1 +
.../enhanced/provider/AiProviderCapabilities.java | 140 +++++++++
.../proxy/enhanced/provider/AiProxyProvider.java | 14 +-
.../plugin/ai/proxy/enhanced/provider/OpenAi.java | 217 ++++++++-----
.../proxy/enhanced/provider/OpenAiCompatible.java | 24 +-
.../enhanced/provider/OpenAiProviderOptions.java | 179 +++++++++++
.../ai/proxy/enhanced/service/AiProxyEngine.java | 3 +-
.../enhanced/provider/AiProxyProviderHttpTest.java | 144 +++++++++
.../provider/OpenAiProviderOptionsTest.java | 338 +++++++++++++++++++++
.../service/AiProxyEngineOrchestrationTest.java | 2 +
.../proxy/enhanced/service/AiProxyEngineTest.java | 138 ++++++++-
.../ai/proxy/AiProxyPluginConfiguration.java | 10 +
.../ai/proxy/AiProxyPluginConfigurationTest.java | 23 +-
16 files changed, 1256 insertions(+), 89 deletions(-)
diff --git a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/README.md
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/README.md
index eda17a845e..477822d853 100644
--- a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/README.md
+++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/README.md
@@ -97,6 +97,85 @@ generic; neither URLs nor model names are used to guess a
dialect. DeepSeek's
profile maps token-limit and reasoning/thinking fields and rejects incompatible
thinking/tool configurations rather than silently changing their meaning.
+## Provider profiles and capabilities
+
+The explicit Provider registry remains the extension point. Built-in
identifiers are
+`OpenAI`, `OpenAICompatible`, `DeepSeek`, `ALiYun`, `OpenAPI`, and `Moonshot`;
separators
+and case are normalized by the registry. Both existing client protocols are
supported.
+The protocol decoder also declares the request operation (`CHAT_COMPLETION` or
+`RESPONSE`), so a profile can restrict operations separately from protocol
support.
+Responses clients continue to use conversion to a Chat upstream; these
declarations
+do not enable native Responses endpoints or other HTTP operations.
+
+`AiProviderCapabilities` declares streaming, function/custom tools, JSON mode,
JSON
+schema output, stream usage and audio output. The default `supports()` follows
the
+declared protocols. The engine validates decoded protocol, operation and
effective
+stream settings before invoking any provider, including custom implementations.
+Providers also validate the final converted payload, including provider
defaults. Unsupported
+features or operations fail as request errors before HTTP execution. Generic
capabilities
+describe the service, not a guarantee for every model it hosts; configure a
narrower
+profile for a model or deployment with additional restrictions. Unknown fields
that
+need no transformation remain in the payload.
+
+Built-in profiles enumerate their supported operations and features
explicitly. Adding
+an enum value does not enable it for existing profiles; review and opt in each
profile.
+
+OpenAI now emits one Chat token-limit field, `max_completion_tokens`. When
both token
+fields are supplied, the modern field takes precedence; gateway `maxTokens`
overrides
+it. This follows the [OpenAI Chat
API](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create).
+Generic compatible profiles preserve the client's field choice unless
configured
+otherwise. `withMaxTokensField("max_tokens")` selects a legacy upstream
dialect through
+Connect's provider adaptation phase, after Responses-to-Chat conversion.
DeepSeek uses
+its existing Connect profile, including token and thinking mappings. Its Chat
capability
+profile accepts function tools and JSON mode and rejects JSON-schema output,
custom
+tools and audio output, following the [DeepSeek Chat
API](https://api-docs.deepseek.com/api/create-chat-completion/).
+
+Provider paths may be relative to a base URL, or the base URL may already
include the
+complete configured operation path. `/v1` is not appended twice. DeepSeek uses
+`/chat/completions`, including when the configured base URL ends in `/v1` or
`/beta`.
+Base URLs retain the existing HTTP(S), no-userinfo/query/fragment restriction;
profile
+query parameters are encoded separately. Header credentials support arbitrary
header
+names and prefixes (including Bearer); query credentials and unauthenticated
local
+services are also available. Generated content-type, accept and credential
headers
+replace conflicting profile headers case-insensitively.
+
+Register another Chat-compatible service as a Spring bean without changing
Engine,
+Protocol or Transport. For example:
+
+```java
+@Bean
+public AiProxyProvider tenantProvider() {
+ ObjectNode defaults = JsonNodeFactory.instance.objectNode();
+ defaults.put("temperature", 0.7);
+ OpenAiProviderOptions options = new OpenAiProviderOptions(
+ "/generate", OpenAiProviderOptions.Authentication.HEADER,
"api-key", "",
+ Map.of("X-Tenant", "tenant-a"), Map.of("api-version", "2026-01"),
+ Map.of("client-alias", "upstream-model"), defaults,
+ AiProviderCapabilities.chat(), null, "max_tokens");
+ return new OpenAiCompatible("tenant-provider", options);
+}
+```
+
+Set the selector's `provider` to `tenant-provider`, its `baseUrl` to the
service base
+URL and its `apiKey` to the credential. The profile also works with a
collection-based
+`AiProxyProviderFactory` without Spring. Existing custom implementations keep
their
+interface contract and the `OpenAi(ProviderProfile)` constructor remains
available.
+
+Model precedence is configured routed model, then client model, then profile
default.
+Alias mapping applies to the selected model. Defaults fill only absent fields;
explicit
+client values survive unless a gateway override is configured. Defaults cannot
replace
+`messages`, `input`, `stream` or internal fallback configuration. Streaming
selection
+therefore remains consistent with Engine retry/response policy. A Connect
dialect
+that changes the selected streaming mode is rejected before HTTP execution.
Profile maps and defaults
+are copied and per-request changes do not mutate the registered profile.
Profile string
+representations omit headers, query values and body defaults.
+
+Provider response differences remain in the request-bound Connect response
adapter or
+the existing `AiProxyProvider.adaptResponse` hook. DeepSeek Chat response
fields, including
+`reasoning_content` and vendor usage fields, remain passthrough for Chat
clients; Connect
+converts reasoning and usage for Responses clients. Providers do not open
connections,
+configure pools/timeouts, write client responses, or implement retry/fallback
policy.
+
## Compatibility and limits
- Existing Chat routes retain their protocol identifier, upstream endpoint,
@@ -119,6 +198,7 @@ thinking/tool configurations rather than silently changing
their meaning.
be inferred from synthetic or missing usage values.
No database/configuration migration is required. Existing Chat routes need no
-change. Responses routes opt in through the selector handle; DeepSeek routes
-should review the stricter dialect validation. Roll back by reverting the
+change except for OpenAI token-field normalization described above. Responses
routes
+opt in through the selector handle; DeepSeek routes should review the
capability and
+dialect validation. Roll back by reverting the
integration commit and restoring any opt-in selectors to `openai-chat`.
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/model/ShenyuAiRequest.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/model/ShenyuAiRequest.java
index b05540257e..4a7ac89499 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/model/ShenyuAiRequest.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/model/ShenyuAiRequest.java
@@ -33,6 +33,8 @@ public final class ShenyuAiRequest {
private String protocol;
+ private Operation operation = Operation.CHAT_COMPLETION;
+
private String model;
private Integer maxTokens;
@@ -68,6 +70,24 @@ public final class ShenyuAiRequest {
return protocol;
}
+ /**
+ * Get the operation established by the protocol decoder.
+ *
+ * @return requested operation
+ */
+ public Operation getOperation() {
+ return operation;
+ }
+
+ /**
+ * Set the operation established by the protocol decoder.
+ *
+ * @param operation requested operation
+ */
+ public void setOperation(final Operation operation) {
+ this.operation = operation;
+ }
+
/**
* Get requested model.
*
@@ -175,4 +195,11 @@ public final class ShenyuAiRequest {
public void setMessages(final List<ShenyuAiMessage> messages) {
this.messages = messages;
}
+
+ /**
+ * Generation operations supported by the current protocol decoders.
+ */
+ public enum Operation {
+ CHAT_COMPLETION, RESPONSE
+ }
}
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/OpenAiChat.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/OpenAiChat.java
index ddd4d8dc72..b8ed66d338 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/OpenAiChat.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/OpenAiChat.java
@@ -73,6 +73,7 @@ public final class OpenAiChat implements AiProxyProtocol {
new ShenyuAiRequest();
request.setProtocol(NAME);
+ request.setOperation(ShenyuAiRequest.Operation.CHAT_COMPLETION);
request.setRawBody(rawBody);
request.setRawPayload(payload.deepCopy());
request.setModel(readOptionalText(
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/OpenAiResponses.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/OpenAiResponses.java
index d578125e7f..ead46e070a 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/OpenAiResponses.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/protocol/OpenAiResponses.java
@@ -65,6 +65,7 @@ public final class OpenAiResponses implements AiProxyProtocol
{
}
ShenyuAiRequest request = new ShenyuAiRequest();
request.setProtocol(NAME);
+ request.setOperation(ShenyuAiRequest.Operation.RESPONSE);
request.setRawBody(rawBody);
request.setRawPayload(payload.deepCopy());
request.setModel(Objects.isNull(model) || model.isNull() ? null :
model.textValue());
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
new file mode 100644
index 0000000000..20d3d1dfe0
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProviderCapabilities.java
@@ -0,0 +1,140 @@
+/*
+ * 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.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;
+import java.util.Set;
+
+/**
+ * Declared provider capabilities, independent of HTTP execution and model
discovery.
+ *
+ * @param protocols supported client protocols
+ * @param operations supported AI operations
+ * @param features supported request features
+ */
+public record AiProviderCapabilities(Set<String> protocols,
Set<ShenyuAiRequest.Operation> operations, Set<Feature> features) {
+
+ /**
+ * Copy declarations so request handling never mutates provider
configuration.
+ */
+ public AiProviderCapabilities {
+ protocols = Set.copyOf(protocols);
+ operations = Set.copyOf(operations);
+ features = Set.copyOf(features);
+ }
+
+ /**
+ * General Chat upstream capabilities, including conversion from Responses
clients.
+ *
+ * @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),
+ EnumSet.of(Feature.STREAMING, Feature.TOOL_CALLING,
Feature.CUSTOM_TOOLS, Feature.JSON_MODE,
+ Feature.JSON_SCHEMA, Feature.STREAM_USAGE,
Feature.AUDIO_OUTPUT));
+ }
+
+ /**
+ * Validate the decoded request and effective gateway streaming setting.
+ *
+ * @param request decoded request
+ * @param stream effective streaming setting
+ */
+ public void validate(final ShenyuAiRequest request, final boolean stream) {
+ if (!protocols.contains(request.getProtocol())) {
+ reject("protocol " + request.getProtocol());
+ }
+ if (Objects.isNull(request.getOperation()) ||
!operations.contains(request.getOperation())) {
+ reject("operation " + request.getOperation());
+ }
+ if (stream) {
+ require(Feature.STREAMING);
+ }
+ }
+
+ /**
+ * Validate features in the final Chat payload, after conversion and
defaults.
+ *
+ * @param payload final provider payload
+ */
+ public void validatePayload(final JsonNode payload) {
+ if (nonempty(payload.get("tools")) ||
nonempty(payload.get("functions"))) {
+ require(Feature.TOOL_CALLING);
+ }
+ for (String field : new String[] {"tool_choice", "function_call"}) {
+ JsonNode choice = payload.get(field);
+ if (Objects.nonNull(choice) && !choice.isNull() &&
!(choice.isTextual() && "none".equals(choice.asText()))) {
+ require(Feature.TOOL_CALLING);
+ }
+ }
+ JsonNode tools = payload.get("tools");
+ if (Objects.nonNull(tools) && tools.isArray()) {
+ for (JsonNode tool : tools) {
+ if ("custom".equals(tool.path("type").asText())) {
+ require(Feature.CUSTOM_TOOLS);
+ }
+ }
+ }
+ String format = payload.path("response_format").path("type").asText();
+ if ("json_object".equals(format)) {
+ require(Feature.JSON_MODE);
+ } else if ("json_schema".equals(format)) {
+ require(Feature.JSON_SCHEMA);
+ }
+ if (payload.path("stream_options").path("include_usage").asBoolean()) {
+ require(Feature.STREAM_USAGE);
+ }
+ JsonNode modalities = payload.get("modalities");
+ if (Objects.nonNull(modalities) && modalities.isArray()) {
+ for (JsonNode modality : modalities) {
+ if ("audio".equals(modality.asText())) {
+ require(Feature.AUDIO_OUTPUT);
+ }
+ }
+ }
+ }
+
+ private boolean nonempty(final JsonNode node) {
+ return Objects.nonNull(node) && !node.isNull() && !node.isEmpty();
+ }
+
+ private void require(final Feature feature) {
+ if (!features.contains(feature)) {
+ reject("feature " + feature);
+ }
+ }
+
+ private void reject(final String capability) {
+ throw new AiProxyException(AiProxyException.Kind.REQUEST, "AI provider
does not support " + capability, null);
+ }
+
+ /**
+ * Features checked when ShenYu constructs the final upstream request.
+ */
+ public enum Feature {
+ STREAMING, TOOL_CALLING, CUSTOM_TOOLS, JSON_MODE, JSON_SCHEMA,
STREAM_USAGE, 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 2f3ee35140..aff2bed098 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
@@ -22,6 +22,8 @@ import
org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamRequest;
import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamResponse;
import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest;
+import java.util.Objects;
+
/**
* Contract for adapting a ShenYu AI request to a provider request.
*/
@@ -43,6 +45,16 @@ public interface AiProxyProvider {
*/
AiUpstreamRequest buildRequest(ShenyuAiRequest request, AiCommonConfig
config);
+ /**
+ * Get provider feature and operation declarations.
+ * Existing custom providers retain the general Chat capability default.
+ *
+ * @return provider capabilities
+ */
+ default AiProviderCapabilities capabilities() {
+ return AiProviderCapabilities.chat();
+ }
+
/**
* Check whether the provider supports a client protocol.
*
@@ -50,7 +62,7 @@ public interface AiProxyProvider {
* @return whether supported
*/
default boolean supports(final String protocol) {
- return true;
+ return Objects.nonNull(protocol) &&
capabilities().protocols().contains(protocol);
}
/**
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 c41f491a56..ceedf9db2f 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
@@ -22,47 +22,69 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import io.github.agenticai.connect.ProviderProfile;
import org.apache.shenyu.common.exception.ShenyuException;
import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
+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.ShenyuAiRequest;
-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.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;
import java.net.URI;
+import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.TreeMap;
+import java.util.regex.Pattern;
/**
- * OpenAI provider adapter.
+ * OpenAI provider adapter; request construction opens no HTTP connections.
*/
public final class OpenAi implements AiProxyProvider {
public static final String NAME = "openai";
- private static final String CHAT_COMPLETIONS_PATH = "/v1/chat/completions";
+ private static final Pattern HEADER_NAME =
Pattern.compile("[!#$%&'*+.^_`|~0-9A-Za-z-]+");
- private final ProviderProfile profile;
+ private final OpenAiProviderOptions options;
/**
- * Create a generic OpenAI Chat upstream without provider-specific
adaptation.
+ * Create an OpenAI Chat upstream.
*/
public OpenAi() {
- this(null);
+ this(OpenAiProviderOptions.openAi());
}
/**
- * Create a Chat upstream with an explicitly selected provider dialect.
+ * Retain the explicit Connect dialect constructor for existing extensions.
*
- * @param profile provider profile, or null for generic OpenAI
compatibility
+ * @param profile explicitly selected provider dialect
*/
public OpenAi(final ProviderProfile profile) {
- this.profile = profile;
+ this(OpenAiProviderOptions.generic(profile));
+ }
+
+ /**
+ * Create a Chat upstream with explicit provider options.
+ *
+ * @param options provider options
+ */
+ private OpenAi(final OpenAiProviderOptions options) {
+ this.options = Objects.requireNonNull(options, "AI provider options
are required");
+ }
+
+ /**
+ * Create a provider with explicit options while retaining the legacy
dialect constructor.
+ *
+ * @param options provider options
+ * @return configured provider
+ */
+ public static OpenAi withOptions(final OpenAiProviderOptions options) {
+ return new OpenAi(options);
}
@Override
@@ -70,78 +92,95 @@ public final class OpenAi implements AiProxyProvider {
return NAME;
}
+ @Override
+ public AiProviderCapabilities capabilities() {
+ return options.capabilities();
+ }
+
@Override
public AiUpstreamRequest buildRequest(final ShenyuAiRequest request, final
AiCommonConfig config) {
validateConfig(config);
if (!supports(request.getProtocol())) {
- throw new IllegalArgumentException(
- "Unsupported OpenAI protocol: " + request.getProtocol());
+ throw new IllegalArgumentException("Unsupported OpenAI protocol: "
+ request.getProtocol());
}
-
- final ObjectNode payload = request.getRawPayload().deepCopy();
- boolean changed = payload.has("fallbackConfig");
+ boolean stream = request.hasStream() ? request.isStream() :
Boolean.TRUE.equals(config.getStream());
+ capabilities().validate(request, stream);
+ ObjectNode payload = request.getRawPayload().deepCopy();
payload.remove("fallbackConfig");
-
+ options.defaults().fields().forEachRemaining(entry -> {
+ if (!payload.has(entry.getKey())) {
+ payload.set(entry.getKey(), entry.getValue());
+ }
+ });
+ String configuredModel = config.getModel();
+ String model = Objects.nonNull(configuredModel) &&
!configuredModel.isBlank() ? configuredModel :
payload.path("model").asText(null);
+ String mappedModel = Objects.isNull(model) ? null :
options.modelMappings().getOrDefault(model, model);
+ if (Objects.nonNull(mappedModel)) {
+ payload.put("model", mappedModel);
+ }
if (Objects.nonNull(config.getTemperature())) {
payload.put("temperature", config.getTemperature());
- changed = true;
}
if (Objects.nonNull(config.getMaxTokens())) {
+ 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 {
- payload.put("max_tokens", config.getMaxTokens());
- payload.put("max_completion_tokens", 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());
}
- changed = true;
}
-
- final boolean stream = request.hasStream()
- ? request.isStream()
- : Boolean.TRUE.equals(config.getStream());
if (!request.hasStream() && Objects.nonNull(config.getStream())) {
payload.put("stream", stream);
- changed = true;
- }
- // Gateway overrides precede Connect's final protocol/provider
adaptation.
- final ConnectProtocolAdapter adapter = new
ConnectProtocolAdapter(request.getProtocol(), config.getModel(), profile);
- final ObjectNode converted = adapter.convertRequest(payload);
- changed = changed || !payload.equals(converted);
- if (stream) {
- changed = enableStreamUsage(converted) || changed;
- }
-
- final Map<String, List<String>> headers =
- new LinkedHashMap<>();
- headers.put(
- HttpHeaders.AUTHORIZATION,
- List.of("Bearer " + config.getApiKey()));
- headers.put(
- HttpHeaders.CONTENT_TYPE,
- List.of(MediaType.APPLICATION_JSON_VALUE));
- headers.put(
- HttpHeaders.ACCEPT,
- List.of(stream
- ? MediaType.TEXT_EVENT_STREAM_VALUE
- : MediaType.APPLICATION_JSON_VALUE));
-
- final AiUpstreamRequest upstreamRequest =
- new AiUpstreamRequest();
- upstreamRequest.setMethod(HttpMethod.POST.name());
- upstreamRequest.setUri(resolveEndpoint(
- config.getBaseUrl(),
- CHAT_COMPLETIONS_PATH));
- upstreamRequest.setHeaders(headers);
- final String body = changed ? converted.toString() :
request.getRawBody();
- upstreamRequest.setBody(body.getBytes(StandardCharsets.UTF_8));
- upstreamRequest.setResponseAdapter(response ->
adapter.convertResponse(response, stream));
-
- return upstreamRequest;
+ }
+ ConnectProtocolAdapter adapter = new
ConnectProtocolAdapter(request.getProtocol(), mappedModel,
options.connectProfile());
+ ObjectNode converted = adapter.convertRequest(payload);
+ JsonNode convertedStream = converted.get("stream");
+ if (Objects.nonNull(convertedStream) && !convertedStream.isNull() &&
!convertedStream.isBoolean()
+ || converted.path("stream").asBoolean() != stream) {
+ throw new AiProxyException(AiProxyException.Kind.REQUEST, "AI
provider dialect must preserve the selected streaming mode", null);
+ }
+ if (stream &&
capabilities().features().contains(AiProviderCapabilities.Feature.STREAM_USAGE))
{
+ enableStreamUsage(converted);
+ }
+ capabilities().validatePayload(converted);
+ AiUpstreamRequest upstream = new AiUpstreamRequest();
+ upstream.setMethod(HttpMethod.POST.name());
+ upstream.setUri(resolveEndpoint(config));
+ upstream.setHeaders(headers(config, stream));
+ String body = request.getRawPayload().equals(converted) ?
request.getRawBody() : converted.toString();
+ upstream.setBody(body.getBytes(StandardCharsets.UTF_8));
+ upstream.setResponseAdapter(response ->
adapter.convertResponse(response, stream));
+ return upstream;
+ }
+
+ private Map<String, List<String>> headers(final AiCommonConfig config,
final boolean stream) {
+ Map<String, List<String>> headers = new LinkedHashMap<>();
+ options.headers().forEach((name, value) -> putHeader(headers, name,
value));
+ putHeader(headers, HttpHeaders.CONTENT_TYPE,
MediaType.APPLICATION_JSON_VALUE);
+ putHeader(headers, HttpHeaders.ACCEPT, stream ?
MediaType.TEXT_EVENT_STREAM_VALUE : MediaType.APPLICATION_JSON_VALUE);
+ if (options.authentication() ==
OpenAiProviderOptions.Authentication.HEADER) {
+ putHeader(headers, options.credentialName(),
options.credentialPrefix() + config.getApiKey());
+ }
+ return headers;
+ }
+
+ private void putHeader(final Map<String, List<String>> headers, final
String name, final String value) {
+ if (!HEADER_NAME.matcher(name).matches() || value.contains("\r") ||
value.contains("\n")) {
+ throw new IllegalArgumentException("Invalid AI provider header: "
+ name);
+ }
+ headers.keySet().removeIf(existing -> existing.equalsIgnoreCase(name));
+ headers.put(name, List.of(value));
}
- private boolean enableStreamUsage(final ObjectNode payload) {
- final JsonNode current = payload.get("stream_options");
- final ObjectNode streamOptions;
+ private void enableStreamUsage(final ObjectNode payload) {
+ JsonNode current = payload.get("stream_options");
+ ObjectNode streamOptions;
if (Objects.isNull(current) || current.isNull()) {
streamOptions = payload.putObject("stream_options");
} else if (current.isObject()) {
@@ -151,39 +190,57 @@ public final class OpenAi implements AiProxyProvider {
}
if (!streamOptions.has("include_usage")) {
streamOptions.put("include_usage", true);
- return true;
}
- return false;
}
private void validateConfig(final AiCommonConfig config) {
+ Objects.requireNonNull(config, "AI provider configuration is
required");
if (Objects.isNull(config.getBaseUrl()) ||
config.getBaseUrl().isBlank()) {
- throw new IllegalArgumentException("OpenAI baseUrl must not be
empty");
+ throw new IllegalArgumentException("AI baseUrl must not be empty");
}
- if (Objects.isNull(config.getApiKey()) ||
config.getApiKey().isBlank()) {
- throw new IllegalArgumentException("OpenAI apiKey must not be
empty");
+ if (options.authentication() !=
OpenAiProviderOptions.Authentication.NONE
+ && (Objects.isNull(config.getApiKey()) ||
config.getApiKey().isBlank())) {
+ throw new IllegalArgumentException("AI apiKey must not be empty");
}
}
- private URI resolveEndpoint(
- final String baseUrl,
- final String path) {
-
- final String normalizedBaseUrl =
- baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
- final String relativePath =
- path.startsWith("/") ? path.substring(1) : path;
-
- final URI base = URI.create(normalizedBaseUrl);
+ private URI resolveEndpoint(final AiCommonConfig config) {
+ URI base = URI.create(config.getBaseUrl());
if (!("http".equalsIgnoreCase(base.getScheme()) ||
"https".equalsIgnoreCase(base.getScheme()))
|| Objects.isNull(base.getHost()) ||
Objects.nonNull(base.getRawQuery()) || Objects.nonNull(base.getRawFragment())
|| Objects.nonNull(base.getRawUserInfo())) {
throw new IllegalArgumentException("AI baseUrl must be an HTTP(S)
endpoint without credentials, query or fragment");
}
- return base.resolve(base.getPath().endsWith("/v1/") ?
relativePath.substring(3) : relativePath);
+ String path = options.path().startsWith("/") ?
options.path().substring(1) : options.path();
+ String basePath = base.getPath().endsWith("/") ?
base.getPath().substring(0, base.getPath().length() - 1) : base.getPath();
+ URI endpoint;
+ if (basePath.endsWith("/" + path)) {
+ endpoint = URI.create(base.toString().replaceAll("/+$", ""));
+ } else {
+ if (basePath.endsWith("/v1") && path.startsWith("v1/")) {
+ path = path.substring(3);
+ }
+ URI directory = URI.create(base.toString().endsWith("/") ?
base.toString() : base + "/");
+ endpoint = directory.resolve(path);
+ }
+ Map<String, String> query = new TreeMap<>(options.queryParameters());
+ if (options.authentication() ==
OpenAiProviderOptions.Authentication.QUERY) {
+ query.put(options.credentialName(), options.credentialPrefix() +
config.getApiKey());
+ }
+ if (query.isEmpty()) {
+ return endpoint;
+ }
+ StringBuilder uri = new StringBuilder(endpoint.toString()).append('?');
+ query.forEach((name, value) ->
uri.append(encode(name)).append('=').append(encode(value)).append('&'));
+ uri.setLength(uri.length() - 1);
+ return URI.create(uri.toString());
+ }
+
+ private String encode(final String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+",
"%20");
}
@Override
public boolean supports(final String protocol) {
- return OpenAiChat.NAME.equals(protocol) ||
OpenAiResponses.NAME.equals(protocol);
+ return Objects.nonNull(protocol) &&
capabilities().protocols().contains(protocol);
}
}
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiCompatible.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiCompatible.java
index 06daf73886..e86964630c 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiCompatible.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiCompatible.java
@@ -17,11 +17,12 @@
package org.apache.shenyu.plugin.ai.proxy.enhanced.provider;
-import io.github.agenticai.connect.ProviderProfile;
import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamRequest;
import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest;
+import java.util.Objects;
+
/**
* Named OpenAI-compatible profile sharing endpoint and request adaptation.
*/
@@ -32,9 +33,26 @@ public final class OpenAiCompatible implements
AiProxyProvider {
private final OpenAi delegate;
public OpenAiCompatible(final String name) {
+ this(name, Objects.nonNull(name) &&
"DeepSeek".equalsIgnoreCase(name.trim().replace("-", "").replace("_", "")) ?
OpenAiProviderOptions.deepSeek() : OpenAiProviderOptions.generic(null));
+ }
+
+ /**
+ * Register another compatible service without changing Engine, Protocol
or Transport.
+ *
+ * @param name provider identifier
+ * @param options explicitly selected provider options
+ */
+ public OpenAiCompatible(final String name, final OpenAiProviderOptions
options) {
+ if (Objects.isNull(name) || name.isBlank()) {
+ throw new IllegalArgumentException("AI provider name must not be
blank");
+ }
this.name = name;
- // Only an explicitly selected DeepSeek provider enables its dialect
rules.
- this.delegate = new OpenAi("DeepSeek".equalsIgnoreCase(name) ?
ProviderProfile.deepSeekChat() : null);
+ this.delegate = OpenAi.withOptions(options);
+ }
+
+ @Override
+ public AiProviderCapabilities capabilities() {
+ return delegate.capabilities();
}
@Override
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiProviderOptions.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiProviderOptions.java
new file mode 100644
index 0000000000..1c6e29382d
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiProviderOptions.java
@@ -0,0 +1,179 @@
+/*
+ * 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.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.github.agenticai.connect.ProviderProfile;
+
+import java.net.URI;
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Immutable configuration for a named Chat-compatible provider in the
explicit registry.
+ *
+ * @param path relative upstream operation path
+ * @param authentication credential placement
+ * @param credentialName credential header or query parameter name
+ * @param credentialPrefix credential prefix
+ * @param headers provider headers
+ * @param queryParameters provider query parameters
+ * @param modelMappings client or routed model aliases
+ * @param defaults missing-field defaults in the client protocol's payload
+ * @param capabilities provider capability declarations
+ * @param dialect Connect provider dialect, or null for identity adaptation
+ * @param maxTokensField Chat token field, or empty to preserve the client's
field choice
+ */
+public record OpenAiProviderOptions(String path, Authentication
authentication, String credentialName, String credentialPrefix,
+ Map<String, String> headers, Map<String,
String> queryParameters, Map<String, String> modelMappings,
+ ObjectNode defaults,
AiProviderCapabilities capabilities, ProviderProfile dialect, String
maxTokensField) {
+
+ /**
+ * Copy mutable data and reject options that would change the protocol or
transport contract.
+ */
+ public OpenAiProviderOptions {
+ URI relative = URI.create(path);
+ if (path.isBlank() || relative.isAbsolute() || path.startsWith("//")
|| Objects.nonNull(relative.getRawQuery())
+ || Objects.nonNull(relative.getRawFragment())) {
+ throw new IllegalArgumentException("AI provider path must be a
relative path without query or fragment");
+ }
+ Objects.requireNonNull(authentication, "AI provider authentication is
required");
+ Objects.requireNonNull(credentialName, "AI provider credential name is
required");
+ Objects.requireNonNull(credentialPrefix, "AI provider credential
prefix is required");
+ Objects.requireNonNull(capabilities, "AI provider capabilities are
required");
+ if (authentication != Authentication.NONE && credentialName.isBlank())
{
+ throw new IllegalArgumentException("AI provider credential name
must not be blank");
+ }
+ headers = Map.copyOf(headers);
+ queryParameters = Map.copyOf(queryParameters);
+ modelMappings = Map.copyOf(modelMappings);
+ defaults = defaults.deepCopy();
+ for (String field : new String[] {"messages", "input", "stream",
"fallbackConfig"}) {
+ if (defaults.has(field)) {
+ throw new IllegalArgumentException("AI provider defaults must
not replace the request envelope: " + field);
+ }
+ }
+ if (!"".equals(maxTokensField) && !"max_tokens".equals(maxTokensField)
&& !"max_completion_tokens".equals(maxTokensField)) {
+ throw new IllegalArgumentException("AI provider token field must
be max_tokens or max_completion_tokens");
+ }
+ }
+
+ /**
+ * OpenAI options use the current Chat token-limit field.
+ *
+ * @return OpenAI options
+ */
+ public static OpenAiProviderOptions openAi() {
+ return generic(null).withMaxTokensField("max_completion_tokens");
+ }
+
+ /**
+ * Preserve generic compatible payloads unless a dialect is explicitly
supplied.
+ *
+ * @param dialect explicitly selected Connect dialect
+ * @return compatible options
+ */
+ public static OpenAiProviderOptions generic(final ProviderProfile dialect)
{
+ return new OpenAiProviderOptions("/v1/chat/completions",
Authentication.HEADER, "Authorization", "Bearer ",
+ Map.of(), Map.of(), Map.of(),
JsonNodeFactory.instance.objectNode(), AiProviderCapabilities.chat(), dialect,
"");
+ }
+
+ /**
+ * DeepSeek Chat supports function tools and JSON mode, with its Connect
dialect.
+ *
+ * @return DeepSeek options
+ */
+ public static OpenAiProviderOptions deepSeek() {
+ EnumSet<AiProviderCapabilities.Feature> features =
EnumSet.of(AiProviderCapabilities.Feature.STREAMING,
+ AiProviderCapabilities.Feature.TOOL_CALLING,
AiProviderCapabilities.Feature.JSON_MODE,
AiProviderCapabilities.Feature.STREAM_USAGE);
+ AiProviderCapabilities all = AiProviderCapabilities.chat();
+ return new OpenAiProviderOptions("/chat/completions",
Authentication.HEADER, "Authorization", "Bearer ",
+ Map.of(), Map.of(), Map.of(),
JsonNodeFactory.instance.objectNode(),
+ new AiProviderCapabilities(all.protocols(), all.operations(),
features), ProviderProfile.deepSeekChat(), "");
+ }
+
+ /**
+ * Get a defensive copy of provider defaults.
+ *
+ * @return provider defaults
+ */
+ @Override
+ public ObjectNode defaults() {
+ return defaults.deepCopy();
+ }
+
+ /**
+ * Select one Chat token-limit field, implemented in Connect's provider
adaptation phase.
+ *
+ * @param field Chat token-limit field
+ * @return updated options
+ */
+ public OpenAiProviderOptions withMaxTokensField(final String field) {
+ return new OpenAiProviderOptions(path, authentication, credentialName,
credentialPrefix, headers, queryParameters,
+ modelMappings, defaults, capabilities, dialect, field);
+ }
+
+ /**
+ * Get the request-scoped Connect dialect including the explicit
token-field choice.
+ *
+ * @return provider dialect
+ */
+ public ProviderProfile connectProfile() {
+ if (maxTokensField.isEmpty()) {
+ return dialect;
+ }
+ return new ProviderProfile() {
+ @Override
+ public void adapt(final JsonObject payload, final Adaptation
adaptation) {
+ if (Objects.nonNull(dialect)) {
+ dialect.adapt(payload, adaptation);
+ }
+ JsonElement tokens = payload.get("max_completion_tokens");
+ if (Objects.isNull(tokens) || tokens.isJsonNull()) {
+ tokens = payload.get("max_tokens");
+ }
+ if (Objects.nonNull(tokens)) {
+ payload.remove("max_tokens");
+ payload.remove("max_completion_tokens");
+ payload.add(maxTokensField, tokens);
+ }
+ }
+
+ @Override
+ public boolean rendersReasoning() {
+ return Objects.nonNull(dialect) && dialect.rendersReasoning();
+ }
+ };
+ }
+
+ @Override
+ public String toString() {
+ return "OpenAiProviderOptions[path=" + path + ", authentication=" +
authentication + ", capabilities=" + capabilities + "]";
+ }
+
+ /**
+ * Credentials can be placed in a header or query parameter, or omitted.
+ */
+ public enum Authentication {
+ HEADER, QUERY, NONE
+ }
+}
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 42a0811487..e146623e06 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
@@ -182,9 +182,10 @@ public final class AiProxyEngine {
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);
try {
AiUpstreamRequest request = provider.buildRequest(decoded, config);
- boolean stream = decoded.hasStream() ? decoded.isStream() :
Boolean.TRUE.equals(config.getStream());
return new PreparedExchange(protocol, provider, request, stream);
} catch (IllegalArgumentException error) {
throw new AiProxyException(AiProxyException.Kind.PROVIDER,
"Invalid AI provider configuration", error);
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
new file mode 100644
index 0000000000..7453e95ee6
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/AiProxyProviderHttpTest.java
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.proxy.enhanced.provider;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
+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.service.AiProxyEngine;
+import
org.apache.shenyu.plugin.ai.proxy.enhanced.transport.WebClientAiProxyTransport;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Flux;
+import reactor.netty.DisposableServer;
+import reactor.netty.http.server.HttpServer;
+import reactor.test.StepVerifier;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+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.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Providers construct requests; the existing engine and transport execute
real HTTP.
+ */
+class AiProxyProviderHttpTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ @Test
+ void customProfileAppliesHeadersQueryModelsAndTokensThroughRealTransport()
throws Exception {
+ AtomicReference<String> forwarded = new AtomicReference<>();
+ String reply = "{\"choices\":[],\"provider_extra\":{\"kept\":true}}";
+ DisposableServer server =
HttpServer.create().host("127.0.0.1").port(0).handle((request, response) -> {
+ assertEquals("/tenant/generate?api-version=2026-01",
request.uri());
+ assertEquals("gateway-key",
request.requestHeaders().get("api-key"));
+ assertEquals("tenant", request.requestHeaders().get("X-Tenant"));
+ return request.receive().aggregate().asString().flatMap(body -> {
+ forwarded.set(body);
+ return response.header("Content-Type",
"application/json").header("X-Provider",
"custom").sendString(Flux.just(reply)).then();
+ });
+ }).bindNow(TIMEOUT);
+ try {
+ OpenAiProviderOptions options = new
OpenAiProviderOptions("/generate", OpenAiProviderOptions.Authentication.HEADER,
"api-key", "",
+ Map.of("X-Tenant", "tenant"), Map.of("api-version",
"2026-01"), Map.of("alias", "native-model"),
+ MAPPER.createObjectNode(), AiProviderCapabilities.chat(),
null, "max_tokens");
+ OpenAiCompatible provider = new OpenAiCompatible("custom",
options);
+ AiProxyEngine engine = engine(provider);
+ AiCommonConfig config = config(server, "custom", OpenAiChat.NAME);
+ config.setBaseUrl(config.getBaseUrl() + "/tenant");
+ config.setMaxTokens(21);
+ String body =
"{\"model\":\"alias\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"vendor_field\":true}";
+ StepVerifier.create(engine.execute(body, config, Optional.empty(),
response -> {
+ assertEquals(List.of("custom"),
response.getHeaders().get("X-Provider"));
+ return response.getBody().map(bytes -> new String(bytes,
StandardCharsets.UTF_8)).reduce("", String::concat)
+ .doOnNext(received -> assertEquals(reply,
received)).then();
+ })).verifyComplete();
+ JsonNode payload = MAPPER.readTree(forwarded.get());
+ assertEquals("native-model", payload.get("model").asText());
+ assertEquals(21, payload.get("max_tokens").asInt());
+ assertFalse(payload.has("max_completion_tokens"));
+ assertTrue(payload.get("vendor_field").asBoolean());
+ } finally {
+ server.disposeNow(TIMEOUT);
+ }
+ }
+
+ @Test
+ void deepSeekChatStreamConvertsReasoningAndUsageForResponsesClients()
throws Exception {
+ AtomicReference<String> forwarded = new AtomicReference<>();
+ AtomicReference<String> received = new AtomicReference<>();
+ String reply = "data:
{\"id\":\"d\",\"model\":\"deepseek-flash\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"trace\"}}]}\n\n"
+ + "data:
{\"id\":\"d\",\"model\":\"deepseek-flash\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"answer\"}}]}\n\n"
+ + "data:
{\"id\":\"d\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],"
+ +
"\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":5,\"total_tokens\":8}}\n\ndata:
[DONE]\n\n";
+ DisposableServer server =
HttpServer.create().host("127.0.0.1").port(0).handle((request, response) -> {
+ assertEquals("/chat/completions", request.uri());
+ assertEquals("Bearer gateway-key",
request.requestHeaders().get("Authorization"));
+ return request.receive().aggregate().asString().flatMap(body -> {
+ forwarded.set(body);
+ return response.header("Content-Type",
"text/event-stream").sendString(Flux.just(reply)).then();
+ });
+ }).bindNow(TIMEOUT);
+ try {
+ AiProxyEngine engine = engine(new OpenAiCompatible("DeepSeek"));
+ AiCommonConfig config = config(server, "DeepSeek",
OpenAiResponses.NAME);
+ config.setModel("deepseek-flash");
+ String body =
"{\"input\":\"hi\",\"stream\":true,\"max_output_tokens\":20,\"reasoning\":{\"effort\":\"medium\"}}";
+ StepVerifier.create(engine.execute(body, config, Optional.empty(),
response -> response.getBody()
+ .map(bytes -> new String(bytes,
StandardCharsets.UTF_8)).reduce("",
String::concat).doOnNext(received::set).then())).verifyComplete();
+ JsonNode payload = MAPPER.readTree(forwarded.get());
+ assertEquals(20, payload.get("max_tokens").asInt());
+ assertFalse(payload.has("max_completion_tokens"));
+ assertEquals("high", payload.get("reasoning_effort").asText());
+ assertTrue(payload.get("stream").asBoolean());
+ assertTrue(received.get().contains("trace"));
+ assertTrue(received.get().contains("answer"));
+ assertTrue(received.get().contains("event: response.completed"));
+ assertTrue(received.get().contains("\"total_tokens\":8"));
+ } finally {
+ server.disposeNow(TIMEOUT);
+ }
+ }
+
+ 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()));
+ }
+
+ private AiCommonConfig config(final DisposableServer server, final String
provider, final String protocol) {
+ AiCommonConfig config = new AiCommonConfig();
+ config.setProvider(provider);
+ config.setProtocol(protocol);
+ config.setBaseUrl("http://127.0.0.1:" + server.port());
+ config.setApiKey("gateway-key");
+ config.setStream(false);
+ return config;
+ }
+}
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
new file mode 100644
index 0000000000..6b349ed8d9
--- /dev/null
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/provider/OpenAiProviderOptionsTest.java
@@ -0,0 +1,338 @@
+/*
+ * 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.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.model.AiProxyException;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamRequest;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamResponse;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest;
+import
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactory;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiChat;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.service.AiProxyEngine;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.EnumSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class OpenAiProviderOptionsTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final String CHAT = "{ \"model\": \"client-alias\",
\"messages\": [{\"role\":\"user\",\"content\":\"hello\"}], \"stream\": false,
\"vendor_extra\": {\"nested\":[1,2]} }";
+
+ private AiCommonConfig config;
+
+ @BeforeEach
+ void setUp() {
+ config = new AiCommonConfig();
+ config.setBaseUrl("https://example.org/prefix");
+ config.setApiKey("test-key");
+ config.setStream(null);
+ }
+
+ @Test
+ void genericCompatibleProfilePreservesUnknownFieldsAndOriginalBytes()
throws Exception {
+ AiUpstreamRequest result = new
OpenAiCompatible("custom").buildRequest(chat(CHAT), config);
+ assertEquals(CHAT, text(result.getBody()));
+ assertEquals("Bearer test-key",
result.getHeaders().get("Authorization").get(0));
+ assertEquals("https://example.org/prefix/v1/chat/completions",
result.getUri().toString());
+ assertNotNull(new OpenAi(null));
+ }
+
+ @Test
+ void resolvesRootVersionedPrefixedAndCompleteEndpoints() throws Exception {
+ OpenAi provider = new OpenAi();
+ Map<String, String> endpoints = Map.of(
+ "https://example.org",
"https://example.org/v1/chat/completions",
+ "https://example.org/v1/",
"https://example.org/v1/chat/completions",
+ "https://example.org/prefix/v1",
"https://example.org/prefix/v1/chat/completions",
+ "https://example.org/prefix/v1/chat/completions/",
"https://example.org/prefix/v1/chat/completions",
+ "https://example.org/prefix%20one",
"https://example.org/prefix%20one/v1/chat/completions");
+ for (Map.Entry<String, String> entry : endpoints.entrySet()) {
+ config.setBaseUrl(entry.getKey());
+ assertEquals(entry.getValue(), provider.buildRequest(chat(CHAT),
config).getUri().toString());
+ }
+ }
+
+ @Test
+ void resolvesCustomPathHeaderAuthenticationAndEncodedQueryParameters()
throws Exception {
+ OpenAiProviderOptions options = options("/chat/generate",
OpenAiProviderOptions.Authentication.HEADER, "api-key", "",
+ Map.of("X-Tenant", "tenant", "api-key", "stale", "accept",
"stale"), Map.of("version", "2026-01", "tag", "a &b/#"), Map.of(), object("{}"),
+ AiProviderCapabilities.chat(), "");
+ AiUpstreamRequest result = new OpenAiCompatible("custom",
options).buildRequest(chat(CHAT), config);
+
assertEquals("https://example.org/prefix/chat/generate?tag=a%20%26b%2F%23&version=2026-01",
result.getUri().toString());
+ assertEquals(List.of("test-key"), result.getHeaders().get("api-key"));
+ assertEquals(List.of("tenant"), result.getHeaders().get("X-Tenant"));
+ assertEquals(List.of("application/json"),
result.getHeaders().get("Accept"));
+ assertFalse(result.getHeaders().containsKey("Authorization"));
+ assertFalse(result.getHeaders().containsKey("accept"));
+ }
+
+ @Test
+ void queryCredentialsReplaceConfiguredValueAndAreEncodedOnce() throws
Exception {
+ config.setApiKey("a+b &/#%");
+ OpenAiProviderOptions options = options("/generate",
OpenAiProviderOptions.Authentication.QUERY, "key", "",
+ Map.of(), Map.of("key", "stale"), Map.of(), object("{}"),
AiProviderCapabilities.chat(), "");
+ AiUpstreamRequest result = new OpenAiCompatible("custom",
options).buildRequest(chat(CHAT), config);
+
assertEquals("https://example.org/prefix/generate?key=a%2Bb%20%26%2F%23%25",
result.getUri().toString());
+ assertFalse(result.getHeaders().containsKey("Authorization"));
+ }
+
+ @Test
+ void unauthenticatedLocalProvidersDoNotRequireApiKeys() throws Exception {
+ config.setApiKey(null);
+ OpenAiProviderOptions options = options("/generate",
OpenAiProviderOptions.Authentication.NONE, "", "",
+ Map.of(), Map.of(), Map.of(), object("{}"),
AiProviderCapabilities.chat(), "");
+ assertFalse(new OpenAiCompatible("local",
options).buildRequest(chat(CHAT),
config).getHeaders().containsKey("Authorization"));
+ assertThrows(IllegalArgumentException.class, () -> new
OpenAi().buildRequest(chat(CHAT), config));
+ }
+
+ @Test
+ void modelAliasesAndDefaultsRespectClientValuesAndGatewayOverrides()
throws Exception {
+ OpenAiProviderOptions options = options("/generate",
OpenAiProviderOptions.Authentication.HEADER, "Authorization", "Bearer ",
+ Map.of(), Map.of(), Map.of("client-alias", "native-client",
"route-alias", "native-route"),
+
object("{\"model\":\"client-alias\",\"temperature\":0.7,\"vendor_default\":{\"x\":1}}"),
AiProviderCapabilities.chat(), "");
+ OpenAiCompatible provider = new OpenAiCompatible("custom", options);
+ JsonNode first = MAPPER.readTree(provider.buildRequest(chat(CHAT),
config).getBody());
+ assertEquals("native-client", first.get("model").asText());
+ assertEquals(0.7, first.get("temperature").asDouble());
+ assertEquals(1, first.at("/vendor_default/x").asInt());
+ assertEquals(object(CHAT).get("vendor_extra"),
first.get("vendor_extra"));
+ config.setModel("route-alias");
+ config.setTemperature(0.2);
+ JsonNode routed = MAPPER.readTree(provider.buildRequest(chat(CHAT),
config).getBody());
+ assertEquals("native-route", routed.get("model").asText());
+ assertEquals(0.2, routed.get("temperature").asDouble());
+ config.setModel(null);
+ config.setTemperature(null);
+ JsonNode absent =
MAPPER.readTree(provider.buildRequest(chat("{\"messages\":[{\"role\":\"user\"}],\"temperature\":0.4}"),
config).getBody());
+ assertEquals("native-client", absent.get("model").asText());
+ assertEquals(0.4, absent.get("temperature").asDouble());
+ }
+
+ @Test
+ void optionsAreImmutableAndDoNotRenderHeaderQueryOrBodySecrets() throws
Exception {
+ Map<String, String> headers = new LinkedHashMap<>(Map.of("X-Secret",
"header-secret"));
+ ObjectNode defaults = object("{\"secret\":\"body-secret\"}");
+ OpenAiProviderOptions options = options("/generate",
OpenAiProviderOptions.Authentication.HEADER, "Authorization", "Bearer ",
+ headers, Map.of("secret", "query-secret"), Map.of(), defaults,
AiProviderCapabilities.chat(), "");
+ headers.put("X-Secret", "changed");
+ defaults.put("secret", "changed");
+ options.defaults().put("secret", "changed");
+ assertEquals("header-secret", options.headers().get("X-Secret"));
+ assertEquals("body-secret", options.defaults().get("secret").asText());
+ assertThrows(UnsupportedOperationException.class, () ->
options.headers().put("new", "value"));
+ assertFalse(options.toString().contains("header-secret"));
+ assertFalse(options.toString().contains("query-secret"));
+ assertFalse(options.toString().contains("body-secret"));
+ }
+
+ @Test
+ void openAiMapsLegacyTokensAndOverrideToOneField() throws Exception {
+ String body = CHAT.substring(0, CHAT.length() - 1) +
",\"max_tokens\":10,\"max_completion_tokens\":20}";
+ JsonNode first = MAPPER.readTree(new OpenAi().buildRequest(chat(body),
config).getBody());
+ assertEquals(20, first.get("max_completion_tokens").asInt());
+ assertFalse(first.has("max_tokens"));
+ config.setMaxTokens(32);
+ JsonNode overridden = MAPPER.readTree(new
OpenAi().buildRequest(chat(body), config).getBody());
+ assertEquals(32, overridden.get("max_completion_tokens").asInt());
+ assertFalse(overridden.has("max_tokens"));
+ assertEquals(object(CHAT).get("vendor_extra"),
overridden.get("vendor_extra"));
+ config.setMaxTokens(0);
+ assertThrows(IllegalArgumentException.class, () -> new
OpenAi().buildRequest(chat(CHAT), config));
+ }
+
+ @Test
+ void nullModernTokenLimitFallsBackToTheLegacyValue() throws Exception {
+ String body =
"{\"messages\":[{\"role\":\"user\"}],\"max_tokens\":10,\"max_completion_tokens\":null}";
+ JsonNode payload = MAPPER.readTree(new
OpenAi().buildRequest(chat(body), config).getBody());
+ assertEquals(10, payload.get("max_completion_tokens").asInt());
+ assertFalse(payload.has("max_tokens"));
+ }
+
+ @Test
+ 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);
+ JsonNode payload = MAPPER.readTree(upstream.getBody());
+ assertEquals(17, payload.get("max_tokens").asInt());
+ assertFalse(payload.has("max_completion_tokens"));
+ assertFalse(payload.has("max_output_tokens"));
+ assertEquals("hi", payload.at("/messages/0/content").asText());
+ }
+
+ @Test
+ void deepSeekUsesItsExplicitDialectAndPreservesVendorFields() throws
Exception {
+ config.setBaseUrl("https://api.deepseek.com");
+ OpenAiCompatible provider = new OpenAiCompatible("DeepSeek");
+ String body =
"{\"model\":\"deepseek-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],"
+ +
"\"max_completion_tokens\":20,\"reasoning_effort\":\"medium\",\"vendor_extra\":{\"x\":1}}";
+ AiUpstreamRequest upstream = provider.buildRequest(chat(body), config);
+ JsonNode payload = MAPPER.readTree(upstream.getBody());
+ assertEquals("https://api.deepseek.com/chat/completions",
upstream.getUri().toString());
+ assertEquals(20, payload.get("max_tokens").asInt());
+ assertFalse(payload.has("max_completion_tokens"));
+ assertEquals("high", payload.get("reasoning_effort").asText());
+ assertEquals("enabled", payload.at("/thinking/type").asText());
+ assertEquals(1, payload.at("/vendor_extra/x").asInt());
+ config.setBaseUrl("https://api.deepseek.com/beta");
+ assertEquals("https://api.deepseek.com/beta/chat/completions",
provider.buildRequest(chat(body), config).getUri().toString());
+ assertThrows(AiProxyException.class, () ->
provider.buildRequest(chat(body.replace("\"vendor_extra\"",
"\"tool_choice\":\"required\",\"vendor_extra\"")), config));
+ }
+
+ @Test
+ void deepSeekRejectsUnsupportedStructuredOutputAndPreservesJsonMode()
throws Exception {
+ OpenAiCompatible provider = new OpenAiCompatible("DeepSeek");
+ String body =
"{\"model\":\"deepseek-flash\",\"messages\":[{\"role\":\"user\"}],\"response_format\":{\"type\":\"json_schema\"}}";
+ AiProxyException error = assertThrows(AiProxyException.class, () ->
provider.buildRequest(chat(body), config));
+ assertEquals(AiProxyException.Kind.REQUEST, error.getKind());
+ JsonNode jsonMode =
MAPPER.readTree(provider.buildRequest(chat(body.replace("json_schema",
"json_object")), config).getBody());
+ assertEquals("json_object",
jsonMode.at("/response_format/type").asText());
+ }
+
+ @Test
+ void
deepSeekResponseDifferencesStayPassthroughForChatAndConvertForResponses()
throws Exception {
+ OpenAiCompatible provider = new OpenAiCompatible("DeepSeek");
+ String reply =
"{\"id\":\"chat-d\",\"created\":1,\"object\":\"chat.completion\",\"model\":\"deepseek-flash\","
+ +
"\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"reasoning_content\":\"think\",\"content\":\"answer\"},\"finish_reason\":\"stop\"}],"
+ +
"\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":5,\"total_tokens\":8,\"prompt_cache_hit_tokens\":2}}";
+ AiUpstreamResponse response = new AiUpstreamResponse(200,
Map.of("Content-Type", List.of("application/json")), Flux.just(bytes(reply)));
+ assertEquals(reply, text(provider.buildRequest(chat(CHAT),
config).adaptResponse(response).getBody().single().block(Duration.ofSeconds(3))));
+ AiUpstreamRequest request =
provider.buildRequest(responses("{\"model\":\"deepseek-flash\",\"input\":\"hi\"}"),
config);
+ JsonNode converted =
MAPPER.readTree(request.adaptResponse(response).getBody().single().block(Duration.ofSeconds(3)));
+ assertEquals("response", converted.get("object").asText());
+ assertTrue(converted.toString().contains("think"));
+ assertTrue(converted.toString().contains("answer"));
+ assertEquals(8, converted.at("/usage/total_tokens").asInt());
+ }
+
+ @Test
+ void unsupportedOperationsFailBeforeTransportWithoutEngineChanges() {
+ AiProviderCapabilities all = AiProviderCapabilities.chat();
+ AiProviderCapabilities restricted = new
AiProviderCapabilities(all.protocols(),
Set.of(ShenyuAiRequest.Operation.CHAT_COMPLETION), 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 -> {
+ calls.incrementAndGet();
+ return Mono.empty();
+ });
+ config.setProvider("restricted");
+ config.setProtocol(OpenAiResponses.NAME);
+ StepVerifier.create(engine.execute("{\"input\":\"hi\"}", config,
java.util.Optional.empty(), response -> response.getBody().then()))
+ .expectErrorMatches(error -> error instanceof AiProxyException
&& ((AiProxyException) error).getKind() ==
AiProxyException.Kind.REQUEST).verify();
+ assertEquals(0, calls.get());
+ }
+
+ @Test
+ void validatesStreamingToolsJsonAndAudioCapabilitiesOnFinalPayload()
throws Exception {
+ AiProviderCapabilities all = AiProviderCapabilities.chat();
+ AiProviderCapabilities restricted = new
AiProviderCapabilities(all.protocols(), all.operations(), Set.of());
+ OpenAiCompatible provider = new OpenAiCompatible("restricted",
options("/generate", OpenAiProviderOptions.Authentication.NONE, "", "",
+ Map.of(), Map.of(), Map.of(), object("{}"), restricted, ""));
+ for (String feature : List.of("\"stream\":true",
"\"function_call\":{\"name\":\"forced\"}",
"\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"f\"}}]",
+ "\"response_format\":{\"type\":\"json_object\"}",
"\"response_format\":{\"type\":\"json_schema\"}",
"\"modalities\":[\"audio\"]")) {
+ String body = "{\"messages\":[{\"role\":\"user\"}]," + feature +
"}";
+ assertThrows(AiProxyException.class, () ->
provider.buildRequest(chat(body), config));
+ }
+ EnumSet<AiProviderCapabilities.Feature> features =
EnumSet.allOf(AiProviderCapabilities.Feature.class);
+ features.remove(AiProviderCapabilities.Feature.STREAM_USAGE);
+ OpenAiCompatible noUsage = new OpenAiCompatible("no-usage",
options("/generate", OpenAiProviderOptions.Authentication.NONE, "", "",
+ Map.of(), Map.of(), Map.of(), object("{}"), new
AiProviderCapabilities(all.protocols(), all.operations(), features), ""));
+ JsonNode payload =
MAPPER.readTree(noUsage.buildRequest(chat("{\"messages\":[{\"role\":\"user\"}],\"stream\":true}"),
config).getBody());
+ assertFalse(payload.has("stream_options"));
+ assertThrows(AiProxyException.class, () ->
noUsage.buildRequest(chat("{\"messages\":[{\"role\":\"user\"}],\"stream\":true,"
+ + "\"stream_options\":{\"include_usage\":true}}"), config));
+ }
+
+ @Test
+ void dialectCannotChangeTheStreamingModeSelectedByTheEngine() throws
Exception {
+ OpenAiCompatible provider = new OpenAiCompatible("invalid-dialect",
OpenAiProviderOptions.generic((payload, adaptation) ->
payload.addProperty("stream", true)));
+ AiProxyException error = assertThrows(AiProxyException.class, () ->
provider.buildRequest(chat(CHAT), config));
+ assertEquals(AiProxyException.Kind.REQUEST, error.getKind());
+ }
+
+ @Test
+ void rejectsInvalidProviderOptionsEndpointsAndHeaders() throws Exception {
+ for (String base : List.of("ftp://example.org",
"https://user:[email protected]", "https://example.org?secret=value",
"https://example.org#fragment")) {
+ config.setBaseUrl(base);
+ assertThrows(IllegalArgumentException.class, () -> new
OpenAi().buildRequest(chat(CHAT), config));
+ }
+ config.setBaseUrl("https://example.org");
+ for (String path : List.of("https://other.org", "//other.org",
"/generate?key=x", "/generate#fragment")) {
+ assertThrows(IllegalArgumentException.class, () -> options(path,
OpenAiProviderOptions.Authentication.NONE, "", "",
+ Map.of(), Map.of(), Map.of(), object("{}"),
AiProviderCapabilities.chat(), ""));
+ }
+ assertThrows(IllegalArgumentException.class, () ->
OpenAiProviderOptions.openAi().withMaxTokensField("invalid"));
+ assertThrows(IllegalArgumentException.class, () ->
options("/generate", OpenAiProviderOptions.Authentication.NONE, "", "",
+ Map.of(), Map.of(), Map.of(), object("{\"stream\":true}"),
AiProviderCapabilities.chat(), ""));
+ OpenAiCompatible provider = new OpenAiCompatible("invalid-header",
options("/generate", OpenAiProviderOptions.Authentication.HEADER,
"Authorization", "Bearer ",
+ Map.of("X-Header", "bad\r\nvalue"), Map.of(), Map.of(),
object("{}"), AiProviderCapabilities.chat(), ""));
+ assertThrows(IllegalArgumentException.class, () ->
provider.buildRequest(chat(CHAT), config));
+ }
+
+ private OpenAiProviderOptions options(final String path, final
OpenAiProviderOptions.Authentication authentication,
+ final String credentialName, final
String prefix, final Map<String, String> headers,
+ final Map<String, String> query,
final Map<String, String> aliases, final ObjectNode defaults,
+ final AiProviderCapabilities
capabilities, final String tokenField) {
+ return new OpenAiProviderOptions(path, authentication, credentialName,
prefix, headers, query, aliases, defaults, capabilities, null, tokenField);
+ }
+
+ private ShenyuAiRequest chat(final String body) throws Exception {
+ return new OpenAiChat().decodeRequest(body, object(body));
+ }
+
+ private ShenyuAiRequest responses(final String body) throws Exception {
+ return new OpenAiResponses().decodeRequest(body, object(body));
+ }
+
+ private ObjectNode object(final String json) throws Exception {
+ return (ObjectNode) MAPPER.readTree(json);
+ }
+
+ private byte[] bytes(final String body) {
+ return body.getBytes(StandardCharsets.UTF_8);
+ }
+
+ private String text(final byte[] body) {
+ return new String(body, 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/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 b31d6d5116..b6746a82ca 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
@@ -26,6 +26,7 @@ import
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocol;
import
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactory;
import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiChat;
import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
+import
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProviderCapabilities;
import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProvider;
import
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProviderFactory;
import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.OpenAi;
@@ -232,6 +233,7 @@ class AiProxyEngineOrchestrationTest {
AiProxyProvider provider = mock(AiProxyProvider.class);
when(provider.name()).thenReturn("custom");
when(provider.supports("openai-chat")).thenReturn(true);
+
when(provider.capabilities()).thenReturn(AiProviderCapabilities.chat());
when(provider.buildRequest(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any())).thenReturn(new AiUpstreamRequest());
when(provider.adaptResponse(org.mockito.ArgumentMatchers.any())).thenAnswer(invocation
-> {
providerAdapted.set(true);
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 7e72ac4012..7efece125c 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineTest.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyEngineTest.java
@@ -21,10 +21,15 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.shenyu.common.exception.ShenyuException;
import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiProxyException;
import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamRequest;
import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamResponse;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.model.ShenyuAiRequest;
import
org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.AiProxyProtocolFactory;
import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiChat;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
+import
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProviderCapabilities;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProvider;
import
org.apache.shenyu.plugin.ai.proxy.enhanced.provider.AiProxyProviderFactory;
import org.apache.shenyu.plugin.ai.proxy.enhanced.provider.OpenAi;
import org.apache.shenyu.plugin.ai.proxy.enhanced.transport.AiProxyTransport;
@@ -32,9 +37,12 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import reactor.core.publisher.Flux;
@@ -43,6 +51,7 @@ import reactor.test.StepVerifier;
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.junit.jupiter.api.Assertions.assertTrue;
@@ -104,7 +113,7 @@ class AiProxyEngineTest {
assertEquals("gpt-4o-mini", payload.get("model").asText());
assertEquals(256, payload.get("max_completion_tokens").intValue());
- assertEquals(256, payload.get("max_tokens").intValue());
+ assertFalse(payload.has("max_tokens"));
assertTrue(payload.get("unknown").booleanValue());
assertTrue(payload.get("stream_options").get("include_usage").booleanValue());
assertEquals("text/event-stream",
request.getHeaders().get("Accept").get(0));
@@ -130,4 +139,131 @@ class AiProxyEngineTest {
assertEquals("hello",
transportedPayload.get("messages").get(0).get("content").asText());
assertFalse(transportedPayload.get("stream").booleanValue());
}
+
+ @Test
+ void testCustomProviderSupportsOnlyDeclaredProtocols() {
+ final AiProviderCapabilities all = AiProviderCapabilities.chat();
+ final AiProxyProvider provider = customProvider(new
AiProviderCapabilities(Set.of(OpenAiChat.NAME), all.operations(),
all.features()), new AtomicInteger());
+
+ assertTrue(provider.supports(OpenAiChat.NAME));
+ assertFalse(provider.supports(OpenAiResponses.NAME));
+ assertFalse(provider.supports("unknown"));
+ assertFalse(provider.supports(null));
+ }
+
+ @Test
+ void testCustomProviderProtocolRejectedBeforeBuildAndTransport() {
+ 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();
+
+ assertEquals(0, builds.get());
+ assertNull(transportedRequest.get());
+ }
+
+ @Test
+ void testCustomProviderOperationRejectedBeforeBuildAndTransport() {
+ final AiProviderCapabilities all = AiProviderCapabilities.chat();
+ config.setProtocol(OpenAiResponses.NAME);
+ assertCapabilityRejected("{\"input\":\"hello\"}",
+ new AiProviderCapabilities(all.protocols(),
Set.of(ShenyuAiRequest.Operation.CHAT_COMPLETION), all.features()),
"operation");
+ }
+
+ @Test
+ void testCustomProviderRequestStreamRejectedBeforeBuildAndTransport() {
+ final AiProviderCapabilities all = AiProviderCapabilities.chat();
+ config.setStream(false);
+
assertCapabilityRejected("{\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}],\"stream\":true}",
+ new AiProviderCapabilities(all.protocols(), all.operations(),
Set.of()), "STREAMING");
+ }
+
+ @Test
+ void testCustomProviderConfiguredStreamRejectedBeforeBuildAndTransport() {
+ final AiProviderCapabilities all = AiProviderCapabilities.chat();
+ config.setStream(true);
+
assertCapabilityRejected("{\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}",
+ new AiProviderCapabilities(all.protocols(), all.operations(),
Set.of()), "STREAMING");
+ }
+
+ @Test
+ void testCustomProviderExplicitNonStreamingOverridesConfiguration() {
+ final AiProviderCapabilities all = AiProviderCapabilities.chat();
+ final AtomicInteger builds = new AtomicInteger();
+ useCustomProvider(new AiProviderCapabilities(all.protocols(),
all.operations(), Set.of()), builds);
+ config.setStream(true);
+
+
StepVerifier.create(engine.execute("{\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}],\"stream\":false}",
+ config, Optional.empty(), response ->
response.getBody().then())).verifyComplete();
+
+ assertEquals(1, builds.get());
+ assertEquals("https://api.openai.com/v1/chat/completions",
transportedRequest.get().getUri().toString());
+ }
+
+ @Test
+ void testFallbackProviderCapabilitiesCheckedBeforeBuildAndTransport() {
+ final AiProviderCapabilities all = AiProviderCapabilities.chat();
+ final AtomicInteger builds = new AtomicInteger();
+ 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 -> {
+ transports.incrementAndGet();
+ return Mono.error(new
AiProxyException(AiProxyException.Kind.TRANSPORT, "unavailable", null));
+ }, Duration.ZERO);
+ config.setStream(true);
+ final AiCommonConfig fallbackConfig = new AiCommonConfig(config);
+ fallbackConfig.setProvider("custom");
+
+
StepVerifier.create(engine.execute("{\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}",
+ config, Optional.of(fallbackConfig), response ->
response.getBody().then()))
+ .expectErrorMatches(error -> error instanceof AiProxyException
&& ((AiProxyException) error).getKind() ==
AiProxyException.Kind.REQUEST).verify();
+
+ assertEquals(2, transports.get());
+ assertEquals(0, builds.get());
+ }
+
+ private void assertCapabilityRejected(final String body, final
AiProviderCapabilities capabilities, final String capability) {
+ final AtomicInteger builds = new AtomicInteger();
+ useCustomProvider(capabilities, builds);
+
+ StepVerifier.create(engine.execute(body, config, Optional.empty(),
response -> response.getBody().then()))
+ .expectErrorMatches(error -> error instanceof AiProxyException
&& ((AiProxyException) error).getKind() == AiProxyException.Kind.REQUEST
+ && error.getMessage().contains(capability)).verify();
+
+ assertEquals(0, builds.get());
+ assertNull(transportedRequest.get());
+ }
+
+ 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
-> {
+ transportedRequest.set(request);
+ return Mono.just(upstreamResponse);
+ });
+ config.setProvider("custom");
+ }
+
+ private AiProxyProvider customProvider(final AiProviderCapabilities
capabilities, final AtomicInteger builds) {
+ return new AiProxyProvider() {
+ @Override
+ public String name() {
+ return "custom";
+ }
+
+ @Override
+ public AiProviderCapabilities capabilities() {
+ return capabilities;
+ }
+
+ @Override
+ public AiUpstreamRequest buildRequest(final ShenyuAiRequest
request, final AiCommonConfig providerConfig) {
+ builds.incrementAndGet();
+ return new OpenAi().buildRequest(request, providerConfig);
+ }
+ };
+ }
}
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 07bf852251..79170c723c 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
@@ -183,6 +183,16 @@ public class AiProxyPluginConfiguration {
}
+ /**
+ * Create the generic OpenAI-compatible provider profile.
+ *
+ * @return provider profile
+ */
+ @Bean
+ public OpenAiCompatible openAiCompatibleProvider() {
+ return new OpenAiCompatible("OpenAICompatible");
+ }
+
/**
* Create the DeepSeek compatible provider profile.
*
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 9db0b79b02..8ba08de911 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
@@ -18,6 +18,11 @@
package org.apache.shenyu.springboot.starter.plugin.ai.proxy;
import
org.apache.shenyu.plugin.ai.common.spring.ai.registry.AiModelFactoryRegistry;
+import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
+import org.apache.shenyu.plugin.ai.proxy.enhanced.model.AiUpstreamRequest;
+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.protocol.AiProxyProtocolFactory;
import org.apache.shenyu.plugin.ai.proxy.enhanced.protocol.OpenAiResponses;
@@ -54,13 +59,29 @@ class AiProxyPluginConfigurationTest {
.doesNotHaveBean(AiModelFactoryRegistry.class);
AiProxyProviderFactory factory =
context.getBean(AiProxyProviderFactory.class);
assertSame(context.getBean(OpenAiResponses.class),
context.getBean(AiProxyProtocolFactory.class).getProtocol(OpenAiResponses.NAME));
- for (String provider : List.of("OpenAI", "DeepSeek", "ALiYun",
"OpenAPI", "Moonshot")) {
+ 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));
}
});
}
+ @Test
+ void
discoversAnotherCompatibleProviderWithoutEngineProtocolOrTransportChanges() {
+ OpenAiCompatible provider = new OpenAiCompatible("tenant-profile",
OpenAiProviderOptions.generic(null));
+ new
ApplicationContextRunner().withUserConfiguration(AiProxyPluginConfiguration.class)
+ .withBean("tenantProvider", AiProxyProvider.class, () ->
provider).run(context -> {
+
org.assertj.core.api.Assertions.assertThat(context).hasNotFailed();
+ assertSame(provider,
context.getBean(AiProxyProviderFactory.class).getProvider("TENANT_PROFILE"));
+ AiCommonConfig config = new AiCommonConfig();
+ config.setProvider("tenant-profile");
+ config.setBaseUrl("https://example.org");
+ config.setApiKey("key");
+ AiUpstreamRequest request =
context.getBean(AiProxyEngine.class).buildRequest("{\"messages\":[{\"role\":\"user\"}]}",
config);
+ assertEquals("https://example.org/v1/chat/completions",
request.getUri().toString());
+ });
+ }
+
@Test
void allowsACustomTransportAndHonorsTheEnableFlag() {
AiProxyTransport custom = request -> Mono.empty();