Copilot commented on code in PR #6341:
URL: https://github.com/apache/shenyu/pull/6341#discussion_r3252993780


##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-common/src/main/java/org/apache/shenyu/plugin/ai/common/protocol/OpenAiProtocolAdapter.java:
##########
@@ -0,0 +1,145 @@
+/*
+ * 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.common.protocol;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.shenyu.common.utils.JsonUtils;
+import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
+import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest;
+
+import java.util.Objects;
+
+/**
+ * Adapts between OpenAI Chat Completions wire format and internal 
representations.
+ *
+ * <p>Note: deserialization into Spring AI's {@link ChatCompletionRequest} 
preserves fields
+ * modeled by that class (messages, model, temperature, maxTokens, stream, 
tools, etc.).
+ * Provider-specific extension fields not modeled by {@code 
ChatCompletionRequest} are dropped
+ * during deserialization. For the OpenAI-compatible providers this plugin 
currently supports,
+ * all required fields are covered.
+ */
+public final class OpenAiProtocolAdapter {
+
+    private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
+            new com.fasterxml.jackson.databind.ObjectMapper();
+
+    private OpenAiProtocolAdapter() {
+    }
+
+    /**
+     * Resolve the stream flag: client request body takes priority,
+     * falls back to the provided default value.
+     *
+     * @param requestBody    the raw JSON request body
+     * @param fallbackStream the default stream value from admin config
+     * @return true if streaming, false otherwise
+     */
+    public static boolean resolveStream(final String requestBody, final 
Boolean fallbackStream) {
+        if (Objects.isNull(requestBody) || requestBody.isEmpty()) {
+            return Boolean.TRUE.equals(fallbackStream);
+        }
+        final JsonNode root = parseStrict(requestBody);
+        if (Objects.isNull(root)) {
+            return Boolean.TRUE.equals(fallbackStream);
+        }
+        if (root.hasNonNull("stream")) {
+            return root.get("stream").asBoolean();
+        }
+        return Boolean.TRUE.equals(fallbackStream);
+    }
+
+    /**
+     * Parse raw request body directly into ChatCompletionRequest, preserving 
fields
+     * modeled by Spring AI (including reasoning_content in assistant 
messages).
+     *
+     * <p>Spring AI's createRequest() loses reasoning_content, refusal, and 
annotations
+     * when reconstructing ChatCompletionMessage from AssistantMessage.
+     * This method avoids that loss by deserializing the raw JSON directly.
+     *
+     * <p>Also converts max_completion_tokens to max_tokens for broader API 
compatibility.
+     *
+     * @param requestBody the raw JSON request body in OpenAI Chat Completions 
format
+     * @param stream whether this is a streaming request (sets the stream 
field)
+     * @return a ChatCompletionRequest with all fields preserved from the 
original request
+     */
+    public static ChatCompletionRequest toChatCompletionRequest(final String 
requestBody, final boolean stream) {
+        return toChatCompletionRequest(requestBody, stream, null);
+    }
+
+    /**
+     * Parse raw request body directly into ChatCompletionRequest, preserving 
modeled fields.
+     * For model, temperature, max_tokens: client request takes priority;
+     * if missing, falls back to the corresponding field in fallbackConfig.
+     *
+     * @param requestBody     the raw JSON request body in OpenAI Chat 
Completions format
+     * @param stream          whether this is a streaming request
+     * @param fallbackConfig  the admin config used as fallback when client 
omits fields
+     * @return a ChatCompletionRequest with all fields preserved
+     */
+    public static ChatCompletionRequest toChatCompletionRequest(final String 
requestBody,
+            final boolean stream, final AiCommonConfig fallbackConfig) {
+        if (Objects.isNull(requestBody) || requestBody.isEmpty()) {
+            throw new IllegalArgumentException("Request body must not be 
empty");
+        }
+        final JsonNode root = parseStrict(requestBody);
+        if (Objects.isNull(root) || !root.isObject()) {
+            throw new IllegalArgumentException("Invalid request body: expected 
a JSON object");
+        }
+        final ObjectNode mutableRoot = (ObjectNode) root;
+
+        if (root.hasNonNull("max_completion_tokens") && 
!root.hasNonNull("max_tokens")) {
+            final JsonNode tokenNode = root.get("max_completion_tokens");
+            if (!tokenNode.isNumber()) {
+                throw new IllegalArgumentException(
+                        "max_completion_tokens must be a number, got: " + 
tokenNode.getNodeType());
+            }
+            mutableRoot.put("max_tokens", tokenNode.asInt());
+            mutableRoot.remove("max_completion_tokens");
+        }
+
+        if (Objects.nonNull(fallbackConfig)) {
+            if (!root.hasNonNull("model") && 
Objects.nonNull(fallbackConfig.getModel()) && 
!fallbackConfig.getModel().isEmpty()) {
+                mutableRoot.put("model", fallbackConfig.getModel());
+            }
+            if (!root.hasNonNull("temperature") && 
Objects.nonNull(fallbackConfig.getTemperature())) {
+                mutableRoot.put("temperature", 
fallbackConfig.getTemperature());
+            }
+            if (!root.hasNonNull("max_tokens") && 
Objects.nonNull(fallbackConfig.getMaxTokens())) {
+                mutableRoot.put("max_tokens", fallbackConfig.getMaxTokens());
+            }
+        }
+
+        mutableRoot.put("stream", stream);
+
+        final ChatCompletionRequest result = 
JsonUtils.jsonToObject(mutableRoot.toString(), ChatCompletionRequest.class);
+        if (Objects.isNull(result)) {
+            throw new IllegalArgumentException("Failed to parse request body 
into ChatCompletionRequest");
+        }
+        return result;

Review Comment:
   The adapter's core behavior is meant to preserve modeled message fields such 
as reasoning_content/refusal/annotations, but the new tests only cover stream, 
model, temperature, and token merging. Add a serialization/deserialization test 
that includes those assistant-message fields so regressions in the OpenAI 
compatibility fix are caught.



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/handler/AiProxyPluginHandler.java:
##########
@@ -53,10 +51,10 @@ public void handlerPlugin(final PluginData pluginData) {
     @Override
     public void handlerSelector(final SelectorData selectorData) {
         // Invalidate the cache first when the selector is updated.
-        chatClientCache.remove(selectorData.getId());
+        OpenAiApiCache.getInstance().remove(selectorData.getId());
         // Do NOT remove AiProxyApiKeyCache here. Admin will push updated 
AI_PROXY_API_KEY events
         // with refreshed realApiKey after selector changes. Removing here 
introduces a window of misses.
-        if (Objects.isNull(selectorData.getHandle())) {
+        if (Objects.isNull(selectorData.getHandle()) || 
selectorData.getHandle().isEmpty()) {
             return;

Review Comment:
   Returning early for a null or empty selector handle leaves any previously 
cached selector handle in selectorCachedHandle. If a selector is updated to 
clear its AI proxy handle, requests can continue using the stale configuration 
instead of seeing the handle as removed.



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPlugin.java:
##########
@@ -214,86 +213,46 @@ private Optional<ChatClient> resolveFallbackClient(
                     if (LOG.isDebugEnabled()) {
                         LOG.debug("[AiProxy] dynamic fallback config: {}", 
cfg);
                     }
-                    return createDynamicFallbackClient(cfg);
+                    return new FallbackContext(createOpenAiApi(cfg), cfg);
                 })
-                .or(
-                        () -> aiProxyConfigService
-                                .resolveAdminFallbackConfig(primaryConfig, 
selectorHandle)
-                                .map(adminFallbackConfig -> {
-                                    LOG.info("[AiProxy] use admin fallback");
-                                    if (LOG.isDebugEnabled()) {
-                                        LOG.debug("[AiProxy] admin fallback 
config: {}", adminFallbackConfig);
-                                    }
-                                    return 
createAdminFallbackClient(selectorId, adminFallbackConfig);
-                                }));
+                .or(() -> aiProxyConfigService
+                        .resolveAdminFallbackConfig(primaryConfig, 
selectorHandle)
+                        .map(adminFallbackConfig -> {
+                            LOG.info("[AiProxy] use admin fallback");
+                            if (LOG.isDebugEnabled()) {
+                                LOG.debug("[AiProxy] admin fallback config: 
{}", adminFallbackConfig);
+                            }
+                            return new FallbackContext(
+                                    getCachedOpenAiApi(selectorId, 
"adminFallback", adminFallbackConfig),
+                                    adminFallbackConfig);
+                        }));
+    }
+
+    private OpenAiApi getCachedOpenAiApi(final String selectorId, final String 
type, final AiCommonConfig config) {
+        final String cacheKey = selectorId + "|" + type + "_" + 
generateConfigCacheKey(config);
+        return OpenAiApiCache.getInstance().computeIfAbsent(cacheKey, () -> 
createOpenAiApi(config));
     }
 
-    /**
-     * Generate cache key based on config fields excluding apiKey.
-     * This ensures cache consistency even when apiKey is updated at runtime.
-     *
-     * @param config the config
-     * @return cache key hash
-     */
     private int generateConfigCacheKey(final AiCommonConfig config) {
         return Objects.hash(
-                config.getProvider(),
                 config.getBaseUrl(),

Review Comment:
   The OpenAiApi cache key omits the API key even though createOpenAiApi binds 
that key into the cached client. In proxy-key mode, two different proxy keys 
for the same selector/baseUrl/model will reuse the first cached OpenAiApi and 
send subsequent requests with the wrong real API key, which can leak traffic 
across tenants or keep using a rotated key.
   



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/test/java/org/apache/shenyu/plugin/ai/proxy/enhanced/AiProxyPluginTest.java:
##########
@@ -262,61 +251,57 @@ public void testExecuteWithAdminFallback() {
         StepVerifier.create(plugin.doExecute(exchange, 
mock(ShenyuPluginChain.class), selector, rule))
                 .verifyComplete();
 
-        verify(executorService).execute(any(ChatClient.class), 
any(Optional.class), any());
+        verify(executorService).executeDirectCall(any(OpenAiApi.class), 
any(Optional.class), any(ChatCompletionRequest.class), any(String.class));
     }
 
     @Test
     public void testCacheIsUsedForAdminFallbackClient() {
         final AiProxyHandle handle = new AiProxyHandle();
         final AiCommonConfig primaryConfig = new AiCommonConfig();
         primaryConfig.setProvider(AiModelProviderEnum.OPEN_AI.getName());
+        primaryConfig.setBaseUrl("https://api.openai.com";);
+        primaryConfig.setApiKey("test-key");
         final AiCommonConfig fallbackConfig = new AiCommonConfig();
         fallbackConfig.setProvider(AiModelProviderEnum.DEEP_SEEK.getName());
+        fallbackConfig.setBaseUrl("https://api.deepseek.com";);
+        fallbackConfig.setApiKey("fallback-key");
         
         // Cache the handle for the test
         
aiProxyPluginHandler.getSelectorCachedHandle().cachedHandle(CacheKeyUtils.INST.getKey(SELECTOR_ID,
 Constants.DEFAULT_RULE), handle);
-        final ChatResponse chatResponse = mock(ChatResponse.class);
+        final ChatCompletion chatCompletion = mock(ChatCompletion.class);
+        final ResponseEntity<ChatCompletion> responseEntity = 
ResponseEntity.ok(chatCompletion);
         
         // Setup all necessary mocks
         
when(configService.resolvePrimaryConfig(handle)).thenReturn(primaryConfig);
         when(configService.resolveDynamicFallbackConfig(primaryConfig, 
REQUEST_BODY)).thenReturn(Optional.empty());
         when(configService.resolveAdminFallbackConfig(primaryConfig, 
handle)).thenReturn(Optional.of(fallbackConfig));
-        when(configService.extractPrompt(anyString())).thenAnswer(invocation 
-> invocation.getArgument(0));
-        when(executorService.execute(any(), any(), 
any())).thenReturn(Mono.just(chatResponse));
+        when(executorService.executeDirectCall(any(OpenAiApi.class), 
any(Optional.class), any(ChatCompletionRequest.class), 
any(String.class))).thenReturn(Mono.just(responseEntity));
 
         // Execute the test - focus on successful execution rather than cache 
verification
         StepVerifier.create(plugin.doExecute(exchange, 
mock(ShenyuPluginChain.class), selector, rule))
                 .verifyComplete();
-        
+
         // Verify that the configuration methods were called correctly
         verify(configService).resolvePrimaryConfig(handle);
         verify(configService).resolveAdminFallbackConfig(primaryConfig, 
handle);
-        verify(executorService).execute(any(), any(), any());
+        verify(executorService).executeDirectCall(any(OpenAiApi.class), 
any(Optional.class), any(ChatCompletionRequest.class), any(String.class));

Review Comment:
   The updated test explicitly avoids verifying cache behavior, but it remains 
the only admin-fallback cache test path. Either assert OpenAiApiCache reuse 
here or rename/split the test so cache behavior is covered elsewhere.



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-common/src/main/java/org/apache/shenyu/plugin/ai/common/protocol/OpenAiProtocolAdapter.java:
##########
@@ -0,0 +1,145 @@
+/*
+ * 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.common.protocol;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.shenyu.common.utils.JsonUtils;
+import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
+import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest;
+
+import java.util.Objects;
+
+/**
+ * Adapts between OpenAI Chat Completions wire format and internal 
representations.
+ *
+ * <p>Note: deserialization into Spring AI's {@link ChatCompletionRequest} 
preserves fields
+ * modeled by that class (messages, model, temperature, maxTokens, stream, 
tools, etc.).
+ * Provider-specific extension fields not modeled by {@code 
ChatCompletionRequest} are dropped
+ * during deserialization. For the OpenAI-compatible providers this plugin 
currently supports,
+ * all required fields are covered.
+ */
+public final class OpenAiProtocolAdapter {
+
+    private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
+            new com.fasterxml.jackson.databind.ObjectMapper();
+
+    private OpenAiProtocolAdapter() {
+    }
+
+    /**
+     * Resolve the stream flag: client request body takes priority,
+     * falls back to the provided default value.
+     *
+     * @param requestBody    the raw JSON request body
+     * @param fallbackStream the default stream value from admin config
+     * @return true if streaming, false otherwise
+     */
+    public static boolean resolveStream(final String requestBody, final 
Boolean fallbackStream) {
+        if (Objects.isNull(requestBody) || requestBody.isEmpty()) {
+            return Boolean.TRUE.equals(fallbackStream);
+        }
+        final JsonNode root = parseStrict(requestBody);
+        if (Objects.isNull(root)) {
+            return Boolean.TRUE.equals(fallbackStream);
+        }
+        if (root.hasNonNull("stream")) {
+            return root.get("stream").asBoolean();
+        }
+        return Boolean.TRUE.equals(fallbackStream);
+    }
+
+    /**
+     * Parse raw request body directly into ChatCompletionRequest, preserving 
fields
+     * modeled by Spring AI (including reasoning_content in assistant 
messages).
+     *
+     * <p>Spring AI's createRequest() loses reasoning_content, refusal, and 
annotations
+     * when reconstructing ChatCompletionMessage from AssistantMessage.
+     * This method avoids that loss by deserializing the raw JSON directly.
+     *
+     * <p>Also converts max_completion_tokens to max_tokens for broader API 
compatibility.
+     *
+     * @param requestBody the raw JSON request body in OpenAI Chat Completions 
format
+     * @param stream whether this is a streaming request (sets the stream 
field)
+     * @return a ChatCompletionRequest with all fields preserved from the 
original request
+     */
+    public static ChatCompletionRequest toChatCompletionRequest(final String 
requestBody, final boolean stream) {
+        return toChatCompletionRequest(requestBody, stream, null);
+    }
+
+    /**
+     * Parse raw request body directly into ChatCompletionRequest, preserving 
modeled fields.
+     * For model, temperature, max_tokens: client request takes priority;
+     * if missing, falls back to the corresponding field in fallbackConfig.
+     *
+     * @param requestBody     the raw JSON request body in OpenAI Chat 
Completions format
+     * @param stream          whether this is a streaming request
+     * @param fallbackConfig  the admin config used as fallback when client 
omits fields
+     * @return a ChatCompletionRequest with all fields preserved
+     */
+    public static ChatCompletionRequest toChatCompletionRequest(final String 
requestBody,
+            final boolean stream, final AiCommonConfig fallbackConfig) {
+        if (Objects.isNull(requestBody) || requestBody.isEmpty()) {
+            throw new IllegalArgumentException("Request body must not be 
empty");
+        }
+        final JsonNode root = parseStrict(requestBody);
+        if (Objects.isNull(root) || !root.isObject()) {
+            throw new IllegalArgumentException("Invalid request body: expected 
a JSON object");
+        }
+        final ObjectNode mutableRoot = (ObjectNode) root;
+
+        if (root.hasNonNull("max_completion_tokens") && 
!root.hasNonNull("max_tokens")) {
+            final JsonNode tokenNode = root.get("max_completion_tokens");
+            if (!tokenNode.isNumber()) {
+                throw new IllegalArgumentException(
+                        "max_completion_tokens must be a number, got: " + 
tokenNode.getNodeType());
+            }
+            mutableRoot.put("max_tokens", tokenNode.asInt());
+            mutableRoot.remove("max_completion_tokens");
+        }
+
+        if (Objects.nonNull(fallbackConfig)) {
+            if (!root.hasNonNull("model") && 
Objects.nonNull(fallbackConfig.getModel()) && 
!fallbackConfig.getModel().isEmpty()) {
+                mutableRoot.put("model", fallbackConfig.getModel());
+            }
+            if (!root.hasNonNull("temperature") && 
Objects.nonNull(fallbackConfig.getTemperature())) {
+                mutableRoot.put("temperature", 
fallbackConfig.getTemperature());
+            }
+            if (!root.hasNonNull("max_tokens") && 
Objects.nonNull(fallbackConfig.getMaxTokens())) {
+                mutableRoot.put("max_tokens", fallbackConfig.getMaxTokens());
+            }
+        }
+
+        mutableRoot.put("stream", stream);
+
+        final ChatCompletionRequest result = 
JsonUtils.jsonToObject(mutableRoot.toString(), ChatCompletionRequest.class);
+        if (Objects.isNull(result)) {
+            throw new IllegalArgumentException("Failed to parse request body 
into ChatCompletionRequest");

Review Comment:
   JsonUtils.jsonToObject logs the full JSON string when deserialization fails. 
Here that JSON is the client's chat-completion request and can contain 
sensitive prompts or tool data, so malformed/unsupported requests may leak user 
content into gateway logs; parse with a mapper that does not log the payload or 
log only sanitized metadata.



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-common/src/main/java/org/apache/shenyu/plugin/ai/common/protocol/OpenAiProtocolAdapter.java:
##########
@@ -0,0 +1,145 @@
+/*
+ * 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.common.protocol;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.shenyu.common.utils.JsonUtils;
+import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
+import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest;
+
+import java.util.Objects;
+
+/**
+ * Adapts between OpenAI Chat Completions wire format and internal 
representations.
+ *
+ * <p>Note: deserialization into Spring AI's {@link ChatCompletionRequest} 
preserves fields
+ * modeled by that class (messages, model, temperature, maxTokens, stream, 
tools, etc.).
+ * Provider-specific extension fields not modeled by {@code 
ChatCompletionRequest} are dropped
+ * during deserialization. For the OpenAI-compatible providers this plugin 
currently supports,
+ * all required fields are covered.
+ */
+public final class OpenAiProtocolAdapter {
+
+    private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
+            new com.fasterxml.jackson.databind.ObjectMapper();
+
+    private OpenAiProtocolAdapter() {
+    }
+
+    /**
+     * Resolve the stream flag: client request body takes priority,
+     * falls back to the provided default value.
+     *
+     * @param requestBody    the raw JSON request body
+     * @param fallbackStream the default stream value from admin config
+     * @return true if streaming, false otherwise
+     */
+    public static boolean resolveStream(final String requestBody, final 
Boolean fallbackStream) {
+        if (Objects.isNull(requestBody) || requestBody.isEmpty()) {
+            return Boolean.TRUE.equals(fallbackStream);
+        }
+        final JsonNode root = parseStrict(requestBody);
+        if (Objects.isNull(root)) {
+            return Boolean.TRUE.equals(fallbackStream);
+        }
+        if (root.hasNonNull("stream")) {
+            return root.get("stream").asBoolean();
+        }
+        return Boolean.TRUE.equals(fallbackStream);
+    }
+
+    /**
+     * Parse raw request body directly into ChatCompletionRequest, preserving 
fields
+     * modeled by Spring AI (including reasoning_content in assistant 
messages).
+     *
+     * <p>Spring AI's createRequest() loses reasoning_content, refusal, and 
annotations
+     * when reconstructing ChatCompletionMessage from AssistantMessage.
+     * This method avoids that loss by deserializing the raw JSON directly.
+     *
+     * <p>Also converts max_completion_tokens to max_tokens for broader API 
compatibility.
+     *
+     * @param requestBody the raw JSON request body in OpenAI Chat Completions 
format
+     * @param stream whether this is a streaming request (sets the stream 
field)
+     * @return a ChatCompletionRequest with all fields preserved from the 
original request
+     */
+    public static ChatCompletionRequest toChatCompletionRequest(final String 
requestBody, final boolean stream) {
+        return toChatCompletionRequest(requestBody, stream, null);
+    }
+
+    /**
+     * Parse raw request body directly into ChatCompletionRequest, preserving 
modeled fields.
+     * For model, temperature, max_tokens: client request takes priority;
+     * if missing, falls back to the corresponding field in fallbackConfig.
+     *
+     * @param requestBody     the raw JSON request body in OpenAI Chat 
Completions format
+     * @param stream          whether this is a streaming request
+     * @param fallbackConfig  the admin config used as fallback when client 
omits fields
+     * @return a ChatCompletionRequest with all fields preserved
+     */
+    public static ChatCompletionRequest toChatCompletionRequest(final String 
requestBody,
+            final boolean stream, final AiCommonConfig fallbackConfig) {
+        if (Objects.isNull(requestBody) || requestBody.isEmpty()) {
+            throw new IllegalArgumentException("Request body must not be 
empty");
+        }
+        final JsonNode root = parseStrict(requestBody);
+        if (Objects.isNull(root) || !root.isObject()) {
+            throw new IllegalArgumentException("Invalid request body: expected 
a JSON object");
+        }
+        final ObjectNode mutableRoot = (ObjectNode) root;
+
+        if (root.hasNonNull("max_completion_tokens") && 
!root.hasNonNull("max_tokens")) {
+            final JsonNode tokenNode = root.get("max_completion_tokens");
+            if (!tokenNode.isNumber()) {
+                throw new IllegalArgumentException(
+                        "max_completion_tokens must be a number, got: " + 
tokenNode.getNodeType());
+            }
+            mutableRoot.put("max_tokens", tokenNode.asInt());
+            mutableRoot.remove("max_completion_tokens");
+        }
+
+        if (Objects.nonNull(fallbackConfig)) {
+            if (!root.hasNonNull("model") && 
Objects.nonNull(fallbackConfig.getModel()) && 
!fallbackConfig.getModel().isEmpty()) {
+                mutableRoot.put("model", fallbackConfig.getModel());
+            }
+            if (!root.hasNonNull("temperature") && 
Objects.nonNull(fallbackConfig.getTemperature())) {
+                mutableRoot.put("temperature", 
fallbackConfig.getTemperature());
+            }
+            if (!root.hasNonNull("max_tokens") && 
Objects.nonNull(fallbackConfig.getMaxTokens())) {
+                mutableRoot.put("max_tokens", fallbackConfig.getMaxTokens());
+            }

Review Comment:
   Because fallbackConfig values are only applied when the client omits the 
field, an admin/dynamic fallback model is ignored for normal OpenAI requests 
that include a model. When the fallback provider uses a different model name, 
the fallback request will still send the primary client model and can fail 
instead of using the configured fallback model.



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyExecutorService.java:
##########
@@ -39,78 +45,124 @@ public class AiProxyExecutorService {
     private static final Logger LOG = 
LoggerFactory.getLogger(AiProxyExecutorService.class);
 
     /**
-     * Execute the AI call with retry and fallback.
+     * Execute a streaming AI call directly via {@link OpenAiApi}, bypassing 
Spring AI's
+     * {@code createRequest()} which loses fields like {@code 
reasoning_content}.
      *
-     * @param mainClient      the main chat client
-     * @param fallbackClientOpt the optional fallback chat client
-     * @param requestBody     the request body
-     * @return a Mono containing the ChatResponse
+     * @param mainApi         the main OpenAiApi
+     * @param fallbackCtxOpt the optional fallback context (api + config)
+     * @param request        the ChatCompletionRequest with all fields 
preserved
+     * @param requestBody    the original request body for rebuilding fallback 
request
+     * @param stream         whether this is a streaming request
+     * @return a Flux of ChatCompletionChunk
      */
-    public Mono<ChatResponse> execute(final ChatClient mainClient, final 
Optional<ChatClient> fallbackClientOpt, final String requestBody) {
-        final Mono<ChatResponse> mainCall = doChatCall(mainClient, 
requestBody);
+    public Flux<ChatCompletionChunk> executeDirectStream(final OpenAiApi 
mainApi,
+            final Optional<FallbackContext> fallbackCtxOpt, final 
ChatCompletionRequest request,
+            final String requestBody, final boolean stream) {
+        return mainApi.chatCompletionStream(request)
+                .doOnError(e -> UpstreamErrorLogger.logUpstreamError(LOG, e, 
"direct stream"))
+                .retryWhen(Retry.max(1)
+                        .filter(AiProxyExecutorService::isRetryable)
+                        .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) 
-> {
+                            LOG.warn("Direct stream retry exhausted. 
Triggering fallback.",
+                                    retrySignal.failure());
+                            return new NonTransientAiException(
+                                    "Direct stream failed after 1 retry. 
Triggering fallback.",
+                                    retrySignal.failure());
+                        }))
+                .onErrorResume(NonTransientAiException.class,
+                        throwable -> handleDirectFallbackStream(throwable, 
fallbackCtxOpt, requestBody, stream));
+    }
 
-        return mainCall
+    /**
+     * Execute a non-streaming AI call directly via {@link OpenAiApi}.
+     *
+     * @param mainApi         the main OpenAiApi
+     * @param fallbackCtxOpt the optional fallback context (api + config)
+     * @param request        the ChatCompletionRequest with all fields 
preserved
+     * @param requestBody    the original request body for rebuilding fallback 
request
+     * @return a Mono of ResponseEntity containing ChatCompletion
+     */
+    public Mono<ResponseEntity<ChatCompletion>> executeDirectCall(final 
OpenAiApi mainApi,
+            final Optional<FallbackContext> fallbackCtxOpt, final 
ChatCompletionRequest request,
+            final String requestBody) {
+        return Mono.fromCallable(() -> mainApi.chatCompletionEntity(request))
+                .subscribeOn(Schedulers.boundedElastic())
+                .doOnError(e -> UpstreamErrorLogger.logUpstreamError(LOG, e, 
"direct call"))
                 .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
-                        .filter(throwable -> !(throwable instanceof 
NonTransientAiException))
+                        .filter(AiProxyExecutorService::isRetryable)
                         .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) 
-> {
-                            LOG.warn("Retries exhausted for AI call after {} 
attempts.",
+                            LOG.warn("Direct call retries exhausted after {} 
attempts. Triggering fallback.",
                                     retrySignal.totalRetries(), 
retrySignal.failure());
-                            return new NonTransientAiException("Retries 
exhausted. Triggering fallback.",
+                            return new NonTransientAiException("Direct call 
retries exhausted. Triggering fallback.",
                                     retrySignal.failure());
                         }))
                 .onErrorResume(NonTransientAiException.class,
-                        throwable -> handleFallback(throwable, 
fallbackClientOpt, requestBody));
+                        throwable -> handleDirectFallbackCall(throwable, 
fallbackCtxOpt, requestBody));
     }
 
-    protected Mono<ChatResponse> doChatCall(final ChatClient client, final 
String requestBody) {
-        return Mono.fromCallable(() -> 
client.prompt().user(requestBody).call().chatResponse())
-                .subscribeOn(Schedulers.boundedElastic());
+    private Flux<ChatCompletionChunk> handleDirectFallbackStream(final 
Throwable throwable,
+            final Optional<FallbackContext> fallbackCtxOpt, final String 
requestBody, final boolean stream) {
+        LOG.warn("Main direct stream failed, attempting fallback...", 
throwable);
+
+        if (fallbackCtxOpt.isEmpty()) {
+            return Flux.error(throwable);
+        }
+
+        final FallbackContext ctx = fallbackCtxOpt.get();
+        LOG.info("Using fallback OpenAiApi for direct stream");
+        final ChatCompletionRequest fallbackRequest = 
OpenAiProtocolAdapter.toChatCompletionRequest(
+                requestBody, stream, ctx.config());
+        return ctx.api().chatCompletionStream(fallbackRequest);
     }
 
-    private Mono<ChatResponse> handleFallback(final Throwable throwable, final 
Optional<ChatClient> fallbackClientOpt, final String requestBody) {
-        LOG.warn("AI main call failed or retries exhausted, attempting to 
fallback...", throwable);
+    private Mono<ResponseEntity<ChatCompletion>> 
handleDirectFallbackCall(final Throwable throwable,
+            final Optional<FallbackContext> fallbackCtxOpt, final String 
requestBody) {
+        LOG.warn("Main direct call failed, attempting fallback...", throwable);
 
-        if (fallbackClientOpt.isEmpty()) {
+        if (fallbackCtxOpt.isEmpty()) {
             return Mono.error(throwable);
         }
 
-        return 
SimpleModelFallbackStrategy.INSTANCE.fallback(fallbackClientOpt.get(), 
requestBody, throwable);
+        final FallbackContext ctx = fallbackCtxOpt.get();
+        LOG.info("Using fallback OpenAiApi for direct call");
+        final ChatCompletionRequest fallbackRequest = 
OpenAiProtocolAdapter.toChatCompletionRequest(
+                requestBody, false, ctx.config());
+        return Mono.fromCallable(() -> 
ctx.api().chatCompletionEntity(fallbackRequest))
+                .subscribeOn(Schedulers.boundedElastic());
     }
 
     /**
-     * Execute the AI call with retry and fallback.
-     *
-     * @param mainClient      the main chat client
-     * @param fallbackClientOpt the optional fallback chat client
-     * @param requestBody     the request body
-     * @return a Flux containing the ChatResponse
+     * Determine if the error is retryable.
+     * Retries transient network errors and retryable HTTP statuses (429, 5xx).
+     * Non-retryable: NonTransientAiException, client errors (400/401/403/404).
      */
-    public Flux<ChatResponse> executeStream(final ChatClient mainClient, final 
Optional<ChatClient> fallbackClientOpt, final String requestBody) {
-        final Flux<ChatResponse> mainStream = doChatStream(mainClient, 
requestBody);
-
-        return mainStream
-                .retryWhen(Retry.max(1)
-                        .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) 
-> {
-                            LOG.warn("Retrying stream once failed. Attempts: 
{}. Triggering fallback.",
-                                    retrySignal.totalRetries(), 
retrySignal.failure());
-                            return new NonTransientAiException("Stream failed 
after 1 retry. Triggering fallback.", retrySignal.failure());
-                        }))
-                .onErrorResume(NonTransientAiException.class,
-                        throwable -> handleFallbackStream(throwable, 
fallbackClientOpt, requestBody));
-    }
-
-    protected Flux<ChatResponse> doChatStream(final ChatClient client, final 
String requestBody) {
-        return Flux.defer(() -> 
client.prompt().user(requestBody).stream().chatResponse())
-                .subscribeOn(Schedulers.boundedElastic());
+    private static boolean isRetryable(final Throwable throwable) {
+        if (throwable instanceof NonTransientAiException) {
+            return false;
+        }
+        final WebClientResponseException webClientEx = 
findWebClientResponseException(throwable);
+        if (Objects.nonNull(webClientEx)) {
+            final int status = webClientEx.getStatusCode().value();
+            return status == 429 || status >= 500;
+        }

Review Comment:
   For WebClientResponseException statuses that isRetryable returns false for 
(for example 401/403/404), Reactor propagates the original exception from 
retryWhen, so the onErrorResume(NonTransientAiException.class) fallback path is 
never invoked. That means a configured fallback provider will be skipped for 
non-retryable primary failures such as expired primary credentials, whereas 
fallback should be independent from retry eligibility.



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-common/src/main/java/org/apache/shenyu/plugin/ai/common/protocol/OpenAiProtocolAdapter.java:
##########
@@ -0,0 +1,145 @@
+/*
+ * 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.common.protocol;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.shenyu.common.utils.JsonUtils;
+import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
+import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest;
+
+import java.util.Objects;
+
+/**
+ * Adapts between OpenAI Chat Completions wire format and internal 
representations.
+ *
+ * <p>Note: deserialization into Spring AI's {@link ChatCompletionRequest} 
preserves fields
+ * modeled by that class (messages, model, temperature, maxTokens, stream, 
tools, etc.).
+ * Provider-specific extension fields not modeled by {@code 
ChatCompletionRequest} are dropped
+ * during deserialization. For the OpenAI-compatible providers this plugin 
currently supports,
+ * all required fields are covered.
+ */
+public final class OpenAiProtocolAdapter {
+
+    private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
+            new com.fasterxml.jackson.databind.ObjectMapper();
+
+    private OpenAiProtocolAdapter() {
+    }
+
+    /**
+     * Resolve the stream flag: client request body takes priority,
+     * falls back to the provided default value.
+     *
+     * @param requestBody    the raw JSON request body
+     * @param fallbackStream the default stream value from admin config
+     * @return true if streaming, false otherwise
+     */
+    public static boolean resolveStream(final String requestBody, final 
Boolean fallbackStream) {
+        if (Objects.isNull(requestBody) || requestBody.isEmpty()) {
+            return Boolean.TRUE.equals(fallbackStream);
+        }
+        final JsonNode root = parseStrict(requestBody);
+        if (Objects.isNull(root)) {
+            return Boolean.TRUE.equals(fallbackStream);
+        }
+        if (root.hasNonNull("stream")) {
+            return root.get("stream").asBoolean();
+        }
+        return Boolean.TRUE.equals(fallbackStream);
+    }
+
+    /**
+     * Parse raw request body directly into ChatCompletionRequest, preserving 
fields
+     * modeled by Spring AI (including reasoning_content in assistant 
messages).
+     *
+     * <p>Spring AI's createRequest() loses reasoning_content, refusal, and 
annotations
+     * when reconstructing ChatCompletionMessage from AssistantMessage.
+     * This method avoids that loss by deserializing the raw JSON directly.
+     *
+     * <p>Also converts max_completion_tokens to max_tokens for broader API 
compatibility.
+     *
+     * @param requestBody the raw JSON request body in OpenAI Chat Completions 
format
+     * @param stream whether this is a streaming request (sets the stream 
field)
+     * @return a ChatCompletionRequest with all fields preserved from the 
original request

Review Comment:
   This return description still says the request has all fields preserved, but 
the class-level documentation states provider-specific fields not modeled by 
ChatCompletionRequest are dropped during deserialization. Narrow this wording 
to modeled fields to avoid documenting a stronger guarantee than the adapter 
provides.



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-common/src/main/java/org/apache/shenyu/plugin/ai/common/config/AiCommonConfig.java:
##########
@@ -50,7 +50,7 @@ public class AiCommonConfig {
     /**
      * temperature.
      */
-    private Double temperature = 0.8;
+    private Double temperature;

Review Comment:
   Removing the default temperature from AiCommonConfig changes behavior for 
every AI plugin/factory that creates a new AiCommonConfig, not just the 
ai-proxy adapter: OpenAiModelFactory and DeepSeekModelFactory will no longer 
set the previous 0.8 default when callers omit temperature. If only the proxy 
request adapter needs to avoid injecting a default, keep the shared config 
default or isolate that behavior to the adapter.
   



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-common/src/main/java/org/apache/shenyu/plugin/ai/common/protocol/OpenAiProtocolAdapter.java:
##########
@@ -0,0 +1,145 @@
+/*
+ * 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.common.protocol;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.shenyu.common.utils.JsonUtils;
+import org.apache.shenyu.plugin.ai.common.config.AiCommonConfig;
+import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest;
+
+import java.util.Objects;
+
+/**
+ * Adapts between OpenAI Chat Completions wire format and internal 
representations.
+ *
+ * <p>Note: deserialization into Spring AI's {@link ChatCompletionRequest} 
preserves fields
+ * modeled by that class (messages, model, temperature, maxTokens, stream, 
tools, etc.).
+ * Provider-specific extension fields not modeled by {@code 
ChatCompletionRequest} are dropped
+ * during deserialization. For the OpenAI-compatible providers this plugin 
currently supports,
+ * all required fields are covered.
+ */
+public final class OpenAiProtocolAdapter {
+
+    private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
+            new com.fasterxml.jackson.databind.ObjectMapper();
+
+    private OpenAiProtocolAdapter() {
+    }
+
+    /**
+     * Resolve the stream flag: client request body takes priority,
+     * falls back to the provided default value.
+     *
+     * @param requestBody    the raw JSON request body
+     * @param fallbackStream the default stream value from admin config
+     * @return true if streaming, false otherwise
+     */
+    public static boolean resolveStream(final String requestBody, final 
Boolean fallbackStream) {
+        if (Objects.isNull(requestBody) || requestBody.isEmpty()) {
+            return Boolean.TRUE.equals(fallbackStream);
+        }
+        final JsonNode root = parseStrict(requestBody);
+        if (Objects.isNull(root)) {
+            return Boolean.TRUE.equals(fallbackStream);
+        }
+        if (root.hasNonNull("stream")) {
+            return root.get("stream").asBoolean();
+        }
+        return Boolean.TRUE.equals(fallbackStream);
+    }
+
+    /**
+     * Parse raw request body directly into ChatCompletionRequest, preserving 
fields
+     * modeled by Spring AI (including reasoning_content in assistant 
messages).
+     *
+     * <p>Spring AI's createRequest() loses reasoning_content, refusal, and 
annotations
+     * when reconstructing ChatCompletionMessage from AssistantMessage.
+     * This method avoids that loss by deserializing the raw JSON directly.
+     *
+     * <p>Also converts max_completion_tokens to max_tokens for broader API 
compatibility.
+     *
+     * @param requestBody the raw JSON request body in OpenAI Chat Completions 
format
+     * @param stream whether this is a streaming request (sets the stream 
field)
+     * @return a ChatCompletionRequest with all fields preserved from the 
original request
+     */
+    public static ChatCompletionRequest toChatCompletionRequest(final String 
requestBody, final boolean stream) {
+        return toChatCompletionRequest(requestBody, stream, null);
+    }
+
+    /**
+     * Parse raw request body directly into ChatCompletionRequest, preserving 
modeled fields.
+     * For model, temperature, max_tokens: client request takes priority;
+     * if missing, falls back to the corresponding field in fallbackConfig.
+     *
+     * @param requestBody     the raw JSON request body in OpenAI Chat 
Completions format
+     * @param stream          whether this is a streaming request
+     * @param fallbackConfig  the admin config used as fallback when client 
omits fields
+     * @return a ChatCompletionRequest with all fields preserved
+     */
+    public static ChatCompletionRequest toChatCompletionRequest(final String 
requestBody,
+            final boolean stream, final AiCommonConfig fallbackConfig) {
+        if (Objects.isNull(requestBody) || requestBody.isEmpty()) {
+            throw new IllegalArgumentException("Request body must not be 
empty");
+        }
+        final JsonNode root = parseStrict(requestBody);
+        if (Objects.isNull(root) || !root.isObject()) {
+            throw new IllegalArgumentException("Invalid request body: expected 
a JSON object");
+        }
+        final ObjectNode mutableRoot = (ObjectNode) root;
+
+        if (root.hasNonNull("max_completion_tokens") && 
!root.hasNonNull("max_tokens")) {
+            final JsonNode tokenNode = root.get("max_completion_tokens");
+            if (!tokenNode.isNumber()) {
+                throw new IllegalArgumentException(
+                        "max_completion_tokens must be a number, got: " + 
tokenNode.getNodeType());
+            }
+            mutableRoot.put("max_tokens", tokenNode.asInt());

Review Comment:
   This accepts any JSON number for max_completion_tokens and converts it with 
asInt(), so a decimal value such as 10.5 is silently truncated to 10 instead of 
being rejected as an invalid token count. Check for an integral integer value 
before converting to avoid changing the client's requested limit.
   



##########
shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-proxy/src/main/java/org/apache/shenyu/plugin/ai/proxy/enhanced/service/AiProxyExecutorService.java:
##########
@@ -39,78 +45,124 @@ public class AiProxyExecutorService {
     private static final Logger LOG = 
LoggerFactory.getLogger(AiProxyExecutorService.class);
 
     /**
-     * Execute the AI call with retry and fallback.
+     * Execute a streaming AI call directly via {@link OpenAiApi}, bypassing 
Spring AI's
+     * {@code createRequest()} which loses fields like {@code 
reasoning_content}.
      *
-     * @param mainClient      the main chat client
-     * @param fallbackClientOpt the optional fallback chat client
-     * @param requestBody     the request body
-     * @return a Mono containing the ChatResponse
+     * @param mainApi         the main OpenAiApi
+     * @param fallbackCtxOpt the optional fallback context (api + config)
+     * @param request        the ChatCompletionRequest with all fields 
preserved
+     * @param requestBody    the original request body for rebuilding fallback 
request
+     * @param stream         whether this is a streaming request
+     * @return a Flux of ChatCompletionChunk
      */
-    public Mono<ChatResponse> execute(final ChatClient mainClient, final 
Optional<ChatClient> fallbackClientOpt, final String requestBody) {
-        final Mono<ChatResponse> mainCall = doChatCall(mainClient, 
requestBody);
+    public Flux<ChatCompletionChunk> executeDirectStream(final OpenAiApi 
mainApi,
+            final Optional<FallbackContext> fallbackCtxOpt, final 
ChatCompletionRequest request,
+            final String requestBody, final boolean stream) {
+        return mainApi.chatCompletionStream(request)
+                .doOnError(e -> UpstreamErrorLogger.logUpstreamError(LOG, e, 
"direct stream"))
+                .retryWhen(Retry.max(1)
+                        .filter(AiProxyExecutorService::isRetryable)
+                        .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) 
-> {
+                            LOG.warn("Direct stream retry exhausted. 
Triggering fallback.",
+                                    retrySignal.failure());
+                            return new NonTransientAiException(
+                                    "Direct stream failed after 1 retry. 
Triggering fallback.",
+                                    retrySignal.failure());
+                        }))
+                .onErrorResume(NonTransientAiException.class,
+                        throwable -> handleDirectFallbackStream(throwable, 
fallbackCtxOpt, requestBody, stream));
+    }
 
-        return mainCall
+    /**
+     * Execute a non-streaming AI call directly via {@link OpenAiApi}.
+     *
+     * @param mainApi         the main OpenAiApi
+     * @param fallbackCtxOpt the optional fallback context (api + config)
+     * @param request        the ChatCompletionRequest with all fields 
preserved
+     * @param requestBody    the original request body for rebuilding fallback 
request
+     * @return a Mono of ResponseEntity containing ChatCompletion
+     */
+    public Mono<ResponseEntity<ChatCompletion>> executeDirectCall(final 
OpenAiApi mainApi,
+            final Optional<FallbackContext> fallbackCtxOpt, final 
ChatCompletionRequest request,
+            final String requestBody) {
+        return Mono.fromCallable(() -> mainApi.chatCompletionEntity(request))
+                .subscribeOn(Schedulers.boundedElastic())
+                .doOnError(e -> UpstreamErrorLogger.logUpstreamError(LOG, e, 
"direct call"))
                 .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
-                        .filter(throwable -> !(throwable instanceof 
NonTransientAiException))
+                        .filter(AiProxyExecutorService::isRetryable)
                         .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) 
-> {
-                            LOG.warn("Retries exhausted for AI call after {} 
attempts.",
+                            LOG.warn("Direct call retries exhausted after {} 
attempts. Triggering fallback.",
                                     retrySignal.totalRetries(), 
retrySignal.failure());
-                            return new NonTransientAiException("Retries 
exhausted. Triggering fallback.",
+                            return new NonTransientAiException("Direct call 
retries exhausted. Triggering fallback.",
                                     retrySignal.failure());
                         }))
                 .onErrorResume(NonTransientAiException.class,
-                        throwable -> handleFallback(throwable, 
fallbackClientOpt, requestBody));
+                        throwable -> handleDirectFallbackCall(throwable, 
fallbackCtxOpt, requestBody));
     }
 
-    protected Mono<ChatResponse> doChatCall(final ChatClient client, final 
String requestBody) {
-        return Mono.fromCallable(() -> 
client.prompt().user(requestBody).call().chatResponse())
-                .subscribeOn(Schedulers.boundedElastic());
+    private Flux<ChatCompletionChunk> handleDirectFallbackStream(final 
Throwable throwable,
+            final Optional<FallbackContext> fallbackCtxOpt, final String 
requestBody, final boolean stream) {
+        LOG.warn("Main direct stream failed, attempting fallback...", 
throwable);
+
+        if (fallbackCtxOpt.isEmpty()) {
+            return Flux.error(throwable);
+        }
+
+        final FallbackContext ctx = fallbackCtxOpt.get();
+        LOG.info("Using fallback OpenAiApi for direct stream");
+        final ChatCompletionRequest fallbackRequest = 
OpenAiProtocolAdapter.toChatCompletionRequest(
+                requestBody, stream, ctx.config());
+        return ctx.api().chatCompletionStream(fallbackRequest);
     }
 
-    private Mono<ChatResponse> handleFallback(final Throwable throwable, final 
Optional<ChatClient> fallbackClientOpt, final String requestBody) {
-        LOG.warn("AI main call failed or retries exhausted, attempting to 
fallback...", throwable);
+    private Mono<ResponseEntity<ChatCompletion>> 
handleDirectFallbackCall(final Throwable throwable,
+            final Optional<FallbackContext> fallbackCtxOpt, final String 
requestBody) {
+        LOG.warn("Main direct call failed, attempting fallback...", throwable);
 
-        if (fallbackClientOpt.isEmpty()) {
+        if (fallbackCtxOpt.isEmpty()) {
             return Mono.error(throwable);
         }
 
-        return 
SimpleModelFallbackStrategy.INSTANCE.fallback(fallbackClientOpt.get(), 
requestBody, throwable);
+        final FallbackContext ctx = fallbackCtxOpt.get();
+        LOG.info("Using fallback OpenAiApi for direct call");
+        final ChatCompletionRequest fallbackRequest = 
OpenAiProtocolAdapter.toChatCompletionRequest(
+                requestBody, false, ctx.config());
+        return Mono.fromCallable(() -> 
ctx.api().chatCompletionEntity(fallbackRequest))
+                .subscribeOn(Schedulers.boundedElastic());
     }
 
     /**
-     * Execute the AI call with retry and fallback.
-     *
-     * @param mainClient      the main chat client
-     * @param fallbackClientOpt the optional fallback chat client
-     * @param requestBody     the request body
-     * @return a Flux containing the ChatResponse
+     * Determine if the error is retryable.
+     * Retries transient network errors and retryable HTTP statuses (429, 5xx).
+     * Non-retryable: NonTransientAiException, client errors (400/401/403/404).
      */
-    public Flux<ChatResponse> executeStream(final ChatClient mainClient, final 
Optional<ChatClient> fallbackClientOpt, final String requestBody) {
-        final Flux<ChatResponse> mainStream = doChatStream(mainClient, 
requestBody);
-
-        return mainStream
-                .retryWhen(Retry.max(1)
-                        .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) 
-> {
-                            LOG.warn("Retrying stream once failed. Attempts: 
{}. Triggering fallback.",
-                                    retrySignal.totalRetries(), 
retrySignal.failure());
-                            return new NonTransientAiException("Stream failed 
after 1 retry. Triggering fallback.", retrySignal.failure());
-                        }))
-                .onErrorResume(NonTransientAiException.class,
-                        throwable -> handleFallbackStream(throwable, 
fallbackClientOpt, requestBody));
-    }
-
-    protected Flux<ChatResponse> doChatStream(final ChatClient client, final 
String requestBody) {
-        return Flux.defer(() -> 
client.prompt().user(requestBody).stream().chatResponse())
-                .subscribeOn(Schedulers.boundedElastic());
+    private static boolean isRetryable(final Throwable throwable) {
+        if (throwable instanceof NonTransientAiException) {
+            return false;
+        }
+        final WebClientResponseException webClientEx = 
findWebClientResponseException(throwable);
+        if (Objects.nonNull(webClientEx)) {
+            final int status = webClientEx.getStatusCode().value();
+            return status == 429 || status >= 500;
+        }
+        return true;
     }
 
-    private Flux<ChatResponse> handleFallbackStream(final Throwable throwable, 
final Optional<ChatClient> fallbackClientOpt, final String requestBody) {
-        LOG.warn("AI main stream failed or retries exhausted, attempting to 
fallback...", throwable);
-
-        if (fallbackClientOpt.isEmpty()) {
-            return Flux.error(throwable);
+    private static WebClientResponseException 
findWebClientResponseException(final Throwable e) {
+        Throwable current = e;
+        while (Objects.nonNull(current)) {
+            if (current instanceof WebClientResponseException ex) {
+                return ex;
+            }
+            current = current.getCause();
         }
+        return null;

Review Comment:
   This duplicates the same cause-chain scan that UpstreamErrorLogger already 
implements. Keeping two copies makes it easy for retry classification and 
logging to diverge; expose a shared helper or centralize 
WebClientResponseException extraction in one place.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to