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 5ce2746d0 [integrations][java][python] Preserve Watsonx finish_reason 
in extra_args (#1113)
5ce2746d0 is described below

commit 5ce2746d0ecb95e388798994f69993ce9d734ef5
Author: Ashfaq <[email protected]>
AuthorDate: Mon Sep 14 11:41:33 2026 +0530

    [integrations][java][python] Preserve Watsonx finish_reason in extra_args 
(#1113)
    
    Generated-by: Claude Code 2.1.226 (Claude Sonnet 5)
---
 .../watsonx/WatsonxChatModelConnection.java        | 23 +++++++--
 .../watsonx/WatsonxChatModelConnectionTest.java    | 58 ++++++++++++++++++++++
 .../watsonx/tests/test_watsonx_chat_model.py       | 58 ++++++++++++++++++++++
 .../chat_models/watsonx/watsonx_chat_model.py      |  6 +++
 4 files changed, 140 insertions(+), 5 deletions(-)

diff --git 
a/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnection.java
 
b/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnection.java
index 627ccd969..1c1606c59 100644
--- 
a/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnection.java
+++ 
b/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnection.java
@@ -553,14 +553,20 @@ public class WatsonxChatModelConnection extends 
BaseChatModelConnection {
         }
     }
 
+    /**
+     * Parses the watsonx.ai chat response. When the provider reports a finish 
reason it is carried
+     * verbatim in {@code extraArgs} under {@code finish_reason}, including 
values outside the
+     * documented set, and the entry is absent when the provider reports none.
+     */
     @VisibleForTesting
     static ChatMessage parseResponse(JsonNode response, String modelName) {
         final JsonNode choice = response.required("choices").get(0);
         final JsonNode responseMessage = choice.required("message");
 
         final JsonNode finishReasonNode = choice.get("finish_reason");
+        String finishReason = null;
         if (finishReasonNode != null && !finishReasonNode.isNull()) {
-            final String finishReason = finishReasonNode.asText();
+            finishReason = finishReasonNode.asText();
             if (!"stop".equals(finishReason) && 
!"tool_calls".equals(finishReason)) {
                 LOG.warn(
                         "watsonx.ai chat for model {} finished with reason 
'{}'; the response"
@@ -602,11 +608,18 @@ public class WatsonxChatModelConnection extends 
BaseChatModelConnection {
         }
 
         final JsonNode usage = response.get("usage");
-        if (modelName != null && !modelName.isBlank() && usage != null && 
!usage.isNull()) {
+        final boolean hasUsageMetadata =
+                modelName != null && !modelName.isBlank() && usage != null && 
!usage.isNull();
+        if (hasUsageMetadata || finishReason != null) {
             final Map<String, Object> extraArgs = new 
HashMap<>(chatMessage.getExtraArgs());
-            extraArgs.put("model_name", modelName);
-            extraArgs.put("promptTokens", 
usage.path("prompt_tokens").asLong(0));
-            extraArgs.put("completionTokens", 
usage.path("completion_tokens").asLong(0));
+            if (hasUsageMetadata) {
+                extraArgs.put("model_name", modelName);
+                extraArgs.put("promptTokens", 
usage.path("prompt_tokens").asLong(0));
+                extraArgs.put("completionTokens", 
usage.path("completion_tokens").asLong(0));
+            }
+            if (finishReason != null) {
+                extraArgs.put("finish_reason", finishReason);
+            }
             chatMessage.setExtraArgs(extraArgs);
         }
 
diff --git 
a/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnectionTest.java
 
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnectionTest.java
index 9b1cd5b6d..f594e9329 100644
--- 
a/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnectionTest.java
+++ 
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnectionTest.java
@@ -380,6 +380,64 @@ class WatsonxChatModelConnectionTest {
                 .isEqualTo("ibm/granite-3-3-8b-instruct");
         assertThat(message.getExtraArgs().get("promptTokens")).isEqualTo(100L);
         
assertThat(message.getExtraArgs().get("completionTokens")).isEqualTo(50L);
+        assertThat(message.getExtraArgs()).containsEntry("finish_reason", 
"stop");
+    }
+
+    @Test
+    @DisplayName("A finish reason outside the documented set is stored as 
received")
+    void testParseResponseCarriesUnknownFinishReasonVerbatim() throws 
Exception {
+        JsonNode response =
+                MAPPER.readTree(
+                        "{\"choices\": [{\"index\": 0, \"message\": {\"role\": 
\"assistant\","
+                                + " \"content\": \"hi\"}, \"finish_reason\":"
+                                + " \"some_vendor_reason\"}]}");
+
+        ChatMessage message = 
WatsonxChatModelConnection.parseResponse(response, null);
+
+        assertThat(message.getExtraArgs()).containsEntry("finish_reason", 
"some_vendor_reason");
+    }
+
+    @Test
+    @DisplayName("A response with no finish_reason member yields no key and no 
error")
+    void testParseResponseNoFinishReasonKeyWhenMemberAbsent() throws Exception 
{
+        JsonNode response =
+                MAPPER.readTree(
+                        "{\"choices\": [{\"index\": 0, \"message\": {\"role\": 
\"assistant\","
+                                + " \"content\": \"hi\"}}]}");
+
+        ChatMessage message = 
WatsonxChatModelConnection.parseResponse(response, null);
+
+        assertThat(message.getExtraArgs()).doesNotContainKey("finish_reason");
+    }
+
+    @Test
+    @DisplayName("A response whose finish_reason is JSON null yields no key 
and no error")
+    void testParseResponseNoFinishReasonKeyWhenJsonNull() throws Exception {
+        JsonNode response =
+                MAPPER.readTree(
+                        "{\"choices\": [{\"index\": 0, \"message\": {\"role\": 
\"assistant\","
+                                + " \"content\": \"hi\"}, \"finish_reason\": 
null}]}");
+
+        ChatMessage message = 
WatsonxChatModelConnection.parseResponse(response, null);
+
+        assertThat(message.getExtraArgs()).doesNotContainKey("finish_reason");
+    }
+
+    @Test
+    @DisplayName("The finish reason is captured independently of the token 
metrics")
+    void testParseResponseCarriesFinishReasonWithoutUsage() throws Exception {
+        // modelName is null here, so the usage-metadata branch cannot run; 
this proves
+        // finish_reason capture does not depend on it.
+        JsonNode response =
+                MAPPER.readTree(
+                        "{\"choices\": [{\"index\": 0, \"message\": {\"role\": 
\"assistant\","
+                                + " \"content\": \"hi\"}, \"finish_reason\": 
\"tool_calls\"}]}");
+
+        ChatMessage message = 
WatsonxChatModelConnection.parseResponse(response, null);
+
+        assertThat(message.getExtraArgs())
+                .containsEntry("finish_reason", "tool_calls")
+                .doesNotContainKey("promptTokens");
     }
 
     @Test
diff --git 
a/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_chat_model.py
 
b/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_chat_model.py
index ab79674c6..6d9a38fcf 100644
--- 
a/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_chat_model.py
+++ 
b/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_chat_model.py
@@ -130,6 +130,7 @@ def test_watsonx_chat_mocked(monkeypatch: 
pytest.MonkeyPatch) -> None:
     assert response.extra_args["model_name"] == test_model
     assert response.extra_args["promptTokens"] == 100
     assert response.extra_args["completionTokens"] == 50
+    assert response.extra_args["finish_reason"] == "stop"
     model_inference.assert_called_once_with(
         model_id=test_model,
         api_client=api_client,
@@ -139,6 +140,63 @@ def test_watsonx_chat_mocked(monkeypatch: 
pytest.MonkeyPatch) -> None:
     )
 
 
+def _setup_llm(
+    mock_model: MagicMock, monkeypatch: pytest.MonkeyPatch
+) -> WatsonxChatModelSetup:
+    model_inference = MagicMock(return_value=mock_model)
+    monkeypatch.setattr(
+        
"flink_agents.integrations.chat_models.watsonx.watsonx_chat_model.ModelInference",
+        model_inference,
+    )
+
+    connection = _fake_connection()
+    connection._client = MagicMock()
+
+    def get_resource(name: str, type: ResourceType) -> Resource:
+        return connection
+
+    mock_ctx = MagicMock(spec=ResourceContext)
+    mock_ctx.get_resource = get_resource
+
+    llm = WatsonxChatModelSetup(
+        model=test_model,
+        connection="watsonx",
+        resource_context=mock_ctx,
+    )
+    llm.open()
+    return llm
+
+
+def test_watsonx_chat_carries_unknown_finish_reason_verbatim(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """A finish reason outside the documented set is stored as received."""
+    mock_model = MagicMock()
+    mock_model.chat.return_value = _mock_chat_response(
+        {"role": "assistant", "content": "hi"}, 
finish_reason="some_vendor_reason"
+    )
+    llm = _setup_llm(mock_model, monkeypatch)
+
+    response = llm.chat([ChatMessage(role=MessageRole.USER, content="Hello!")])
+
+    assert response.extra_args["finish_reason"] == "some_vendor_reason"
+
+
+def test_watsonx_chat_no_finish_reason_key_when_none(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """A response with no finish reason yields no key and no error."""
+    mock_model = MagicMock()
+    mock_model.chat.return_value = _mock_chat_response(
+        {"role": "assistant", "content": "hi"}, finish_reason=None
+    )
+    llm = _setup_llm(mock_model, monkeypatch)
+
+    response = llm.chat([ChatMessage(role=MessageRole.USER, content="Hello!")])
+
+    assert "finish_reason" not in response.extra_args
+
+
 def test_watsonx_tool_call_response_mocked(monkeypatch: pytest.MonkeyPatch) -> 
None:
     """Test that tool call responses are converted to the framework format."""
     mock_model = MagicMock()
diff --git 
a/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py 
b/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
index 6544d2218..b553e3f95 100644
--- a/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
+++ b/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
@@ -345,6 +345,10 @@ class WatsonxChatModelConnection(BaseChatModelConnection):
         structured-output translation, so callers stay on the 
prompt-engineering
         fallback. Declaring the parameter keeps a caller-supplied schema out of
         ``**kwargs``, which is forwarded to the provider SDK.
+
+        When the response carries a finish reason, it is available verbatim as
+        ``extra_args["finish_reason"]``; the key is absent when the provider
+        reports none.
         """
         self._reject_unsupported_output_schema(output_schema)
         model_name = kwargs.pop("model", DEFAULT_MODEL)
@@ -401,6 +405,8 @@ class WatsonxChatModelConnection(BaseChatModelConnection):
                 model_name,
                 finish_reason,
             )
+        if finish_reason is not None:
+            extra_args["finish_reason"] = finish_reason
 
         response_message: Dict[str, Any] = choice["message"]
 

Reply via email to