This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 7100b596 [integrations][openai] Preserve provider refusals in the 
Python message converter (#952)
7100b596 is described below

commit 7100b5962be8c040197c4e592c5973aa862340ce
Author: Weiqing Yang <[email protected]>
AuthorDate: Sat Aug 8 06:13:06 2026 -0700

    [integrations][openai] Preserve provider refusals in the Python message 
converter (#952)
---
 .../openai/OpenAIChatCompletionsUtilsTest.java     | 63 ++++++++++++++++++++++
 .../chat_models/openai/openai_utils.py             |  8 ++-
 .../tests/test_openai_native_structured_output.py  |  1 +
 .../openai/tests/test_openai_response_parsing.py   | 52 ++++++++++++++++++
 4 files changed, 123 insertions(+), 1 deletion(-)

diff --git 
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtilsTest.java
 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtilsTest.java
new file mode 100644
index 00000000..9f260366
--- /dev/null
+++ 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtilsTest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.integrations.chatmodels.openai;
+
+import com.openai.models.chat.completions.ChatCompletionMessage;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for how {@link OpenAIChatCompletionsUtils} carries a provider 
refusal from a Chat
+ * Completions message onto the returned {@link ChatMessage}. The converter is 
shared by every Chat
+ * Completions connection in this module.
+ */
+class OpenAIChatCompletionsUtilsTest {
+
+    @Test
+    @DisplayName("A refusal reason is carried into extraArgs")
+    void testRefusalPreservedInExtraArgs() {
+        // A refused response has no content, so the reason is the only thing 
separating it
+        // from a genuinely empty completion.
+        ChatCompletionMessage message =
+                ChatCompletionMessage.builder()
+                        .content(Optional.empty())
+                        .refusal("I cannot help with that")
+                        .build();
+
+        ChatMessage result = 
OpenAIChatCompletionsUtils.convertFromOpenAIMessage(message);
+
+        assertThat(result.getExtraArgs()).containsEntry("refusal", "I cannot 
help with that");
+    }
+
+    @Test
+    @DisplayName("No refusal key is added when the provider did not refuse")
+    void testNoRefusalKeyWhenAbsent() {
+        ChatCompletionMessage message =
+                
ChatCompletionMessage.builder().content("hello").refusal(Optional.empty()).build();
+
+        ChatMessage result = 
OpenAIChatCompletionsUtils.convertFromOpenAIMessage(message);
+
+        assertThat(result.getExtraArgs()).doesNotContainKey("refusal");
+    }
+}
diff --git 
a/python/flink_agents/integrations/chat_models/openai/openai_utils.py 
b/python/flink_agents/integrations/chat_models/openai/openai_utils.py
index 16f49e84..9601d9eb 100644
--- a/python/flink_agents/integrations/chat_models/openai/openai_utils.py
+++ b/python/flink_agents/integrations/chat_models/openai/openai_utils.py
@@ -196,7 +196,11 @@ def convert_to_openai_message(message: ChatMessage) -> 
ChatCompletionMessagePara
 def convert_from_openai_message(
     message: ChatCompletionMessage, extra_args: Dict[str, Any]
 ) -> ChatMessage:
-    """Convert an OpenAI message to a chat message."""
+    """Convert an OpenAI message to a chat message.
+
+    A provider refusal is surfaced under extra_args["refusal"], including when
+    the refusal reason is an empty string.
+    """
     tool_calls = []
     if message.tool_calls:
         # Generate internal UUID for each tool call while preserving
@@ -214,6 +218,8 @@ def convert_from_openai_message(
             }
             for tool_call in message.tool_calls
         ]
+    if message.refusal is not None:
+        extra_args = {**extra_args, "refusal": message.refusal}
     return ChatMessage(
         role=MessageRole(message.role),
         content=message.content or "",
diff --git 
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
index f3149249..ce6909f7 100644
--- 
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
+++ 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
@@ -47,6 +47,7 @@ def _connection() -> OpenAIChatModelConnection:
     mock_message.role = "assistant"
     mock_message.content = "ok"
     mock_message.tool_calls = None
+    mock_message.refusal = None
     mock_client.chat.completions.create.return_value.choices = [
         MagicMock(message=mock_message)
     ]
diff --git 
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_response_parsing.py
 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_response_parsing.py
new file mode 100644
index 00000000..07c5e3d9
--- /dev/null
+++ 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_response_parsing.py
@@ -0,0 +1,52 @@
+################################################################################
+#  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.
+#################################################################################
+import pytest
+from openai.types.chat import ChatCompletionMessage
+
+from flink_agents.integrations.chat_models.openai.openai_utils import (
+    convert_from_openai_message,
+)
+
+
[email protected]("refusal", ["I cannot help with that", ""])
+def test_refusal_is_preserved_in_extra_args(refusal: str) -> None:
+    """A provider refusal reaches the caller through extra_args."""
+    # A refused response carries no content, so the reason is the only thing 
that
+    # distinguishes it from a genuinely empty completion. An empty reason is 
still
+    # a refusal, which a truthiness guard would silently drop. Callers hand in 
an
+    # extra_args already holding token metrics, so recording the reason must 
add to
+    # that dict rather than replace it. The reason belongs in extra_args alone:
+    # folding it into content would make a refusal read as an ordinary answer.
+    message = ChatCompletionMessage(role="assistant", content=None, 
refusal=refusal)
+
+    result = convert_from_openai_message(message, {"promptTokens": 3})
+
+    assert result.extra_args["refusal"] == refusal
+    assert result.extra_args["promptTokens"] == 3
+    assert result.content == ""
+
+
+def test_no_refusal_key_when_refusal_absent() -> None:
+    """A response that was not refused leaves no refusal key behind."""
+    # extra_args is merged back into the outbound assistant message, so a null
+    # refusal key here would be echoed to the provider on every later request.
+    message = ChatCompletionMessage(role="assistant", content="ok", 
refusal=None)
+
+    result = convert_from_openai_message(message, {})
+
+    assert "refusal" not in result.extra_args

Reply via email to