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 0e36efd3 [java][python] IBM watsonx.ai chat model integration (#922)
0e36efd3 is described below

commit 0e36efd34a7fd0543fdcc8ce233d7a87a8835f98
Author: Cansu Kertmen <[email protected]>
AuthorDate: Mon Aug 24 11:46:11 2026 +0200

    [java][python] IBM watsonx.ai chat model integration (#922)
---
 .../flink/agents/api/resource/ResourceName.java    |  12 +
 .../org/apache/flink/agents/api/yaml/Aliases.java  |   4 +
 .../apache/flink/agents/api/yaml/AliasesTest.java  |  16 +
 dist/pom.xml                                       |   5 +
 docs/content/docs/development/chat_models.md       | 157 +++++
 docs/content/docs/development/yaml.md              |   1 +
 docs/content/docs/faq/faq.md                       |   1 +
 integrations/chat-models/pom.xml                   |   1 +
 integrations/chat-models/watsonx/pom.xml           |  64 ++
 .../watsonx/WatsonxChatModelConnection.java        | 711 +++++++++++++++++++++
 .../chatmodels/watsonx/WatsonxChatModelSetup.java  |  75 +++
 .../watsonx/WatsonxChatModelConnectionTest.java    | 617 ++++++++++++++++++
 .../watsonx/WatsonxChatModelLiveTest.java          |  86 +++
 .../watsonx/WatsonxChatModelSetupTest.java         | 114 ++++
 python/flink_agents/api/chat_models/chat_model.py  |   5 +-
 python/flink_agents/api/resource.py                |   8 +
 python/flink_agents/api/yaml/aliases.py            |   4 +
 python/flink_agents/api/yaml/tests/test_aliases.py |  21 +-
 .../chat_models/tests/test_ollama_chat_model.py    |   2 +-
 .../tests/test_output_schema_param_declared.py     |   2 +
 .../integrations/chat_models/watsonx/__init__.py   |  17 +
 .../chat_models/watsonx/tests/__init__.py          |  17 +
 .../watsonx/tests/test_watsonx_chat_model.py       | 393 ++++++++++++
 .../chat_models/watsonx/watsonx_chat_model.py      | 498 +++++++++++++++
 python/pyproject.toml                              |   2 +
 25 files changed, 2830 insertions(+), 3 deletions(-)

diff --git 
a/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java 
b/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java
index 3382ee6a..19dc86de 100644
--- a/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java
+++ b/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java
@@ -95,6 +95,12 @@ public final class ResourceName {
         public static final String VLLM_SETUP =
                 
"org.apache.flink.agents.integrations.chatmodels.openai.VLLMChatModelSetup";
 
+        // IBM watsonx.ai
+        public static final String WATSONX_CONNECTION =
+                
"org.apache.flink.agents.integrations.chatmodels.watsonx.WatsonxChatModelConnection";
+        public static final String WATSONX_SETUP =
+                
"org.apache.flink.agents.integrations.chatmodels.watsonx.WatsonxChatModelSetup";
+
         // Python Wrapper
         public static final String PYTHON_WRAPPER_CONNECTION =
                 
"org.apache.flink.agents.api.chat.model.python.PythonChatModelConnection";
@@ -140,6 +146,12 @@ public final class ResourceName {
             public static final String VLLM_SETUP =
                     
"flink_agents.integrations.chat_models.vllm.vllm_chat_model.VLLMChatModelSetup";
 
+            // IBM watsonx.ai
+            public static final String WATSONX_CONNECTION =
+                    
"flink_agents.integrations.chat_models.watsonx.watsonx_chat_model.WatsonxChatModelConnection";
+            public static final String WATSONX_SETUP =
+                    
"flink_agents.integrations.chat_models.watsonx.watsonx_chat_model.WatsonxChatModelSetup";
+
             private Python() {}
         }
 
diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java 
b/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java
index ec2bb306..ee70e3fe 100644
--- a/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java
+++ b/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java
@@ -90,6 +90,7 @@ public final class Aliases {
         chatConnJava.put("azure_openai", 
ResourceName.ChatModel.AZURE_OPENAI_CONNECTION);
         chatConnJava.put("bedrock", ResourceName.ChatModel.BEDROCK_CONNECTION);
         chatConnJava.put("vllm", ResourceName.ChatModel.VLLM_CONNECTION);
+        chatConnJava.put("watsonx", ResourceName.ChatModel.WATSONX_CONNECTION);
         Map<String, String> chatConnPython = new HashMap<>();
         chatConnPython.put("ollama", 
ResourceName.ChatModel.Python.OLLAMA_CONNECTION);
         chatConnPython.put("openai", 
ResourceName.ChatModel.Python.OPENAI_COMPLETIONS_CONNECTION);
@@ -97,6 +98,7 @@ public final class Aliases {
         chatConnPython.put("tongyi", 
ResourceName.ChatModel.Python.TONGYI_CONNECTION);
         chatConnPython.put("azure_openai", 
ResourceName.ChatModel.Python.AZURE_OPENAI_CONNECTION);
         chatConnPython.put("vllm", 
ResourceName.ChatModel.Python.VLLM_CONNECTION);
+        chatConnPython.put("watsonx", 
ResourceName.ChatModel.Python.WATSONX_CONNECTION);
         ca.put(ResourceType.CHAT_MODEL_CONNECTION, 
buildLangBuckets(chatConnJava, chatConnPython));
 
         // CHAT_MODEL
@@ -109,6 +111,7 @@ public final class Aliases {
         chatJava.put("azure_openai", 
ResourceName.ChatModel.AZURE_OPENAI_SETUP);
         chatJava.put("bedrock", ResourceName.ChatModel.BEDROCK_SETUP);
         chatJava.put("vllm", ResourceName.ChatModel.VLLM_SETUP);
+        chatJava.put("watsonx", ResourceName.ChatModel.WATSONX_SETUP);
         Map<String, String> chatPython = new HashMap<>();
         chatPython.put("ollama", ResourceName.ChatModel.Python.OLLAMA_SETUP);
         chatPython.put("openai", 
ResourceName.ChatModel.Python.OPENAI_COMPLETIONS_SETUP);
@@ -116,6 +119,7 @@ public final class Aliases {
         chatPython.put("tongyi", ResourceName.ChatModel.Python.TONGYI_SETUP);
         chatPython.put("azure_openai", 
ResourceName.ChatModel.Python.AZURE_OPENAI_SETUP);
         chatPython.put("vllm", ResourceName.ChatModel.Python.VLLM_SETUP);
+        chatPython.put("watsonx", ResourceName.ChatModel.Python.WATSONX_SETUP);
         ca.put(ResourceType.CHAT_MODEL, buildLangBuckets(chatJava, 
chatPython));
 
         // EMBEDDING_MODEL_CONNECTION
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java 
b/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java
index ab8d723e..9d4508c1 100644
--- a/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java
+++ b/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java
@@ -103,6 +103,22 @@ class AliasesTest {
         
assertThat(pythonSetup).isEqualTo(ResourceName.ChatModel.Python.VLLM_SETUP);
     }
 
+    @Test
+    void watsonxAliasesResolveForJavaAndPython() {
+        assertThat(
+                        Aliases.resolveClazz(
+                                "watsonx", ResourceType.CHAT_MODEL_CONNECTION, 
Language.JAVA))
+                .isEqualTo(ResourceName.ChatModel.WATSONX_CONNECTION);
+        assertThat(Aliases.resolveClazz("watsonx", ResourceType.CHAT_MODEL, 
Language.JAVA))
+                .isEqualTo(ResourceName.ChatModel.WATSONX_SETUP);
+        assertThat(
+                        Aliases.resolveClazz(
+                                "watsonx", ResourceType.CHAT_MODEL_CONNECTION, 
Language.PYTHON))
+                .isEqualTo(ResourceName.ChatModel.Python.WATSONX_CONNECTION);
+        assertThat(Aliases.resolveClazz("watsonx", ResourceType.CHAT_MODEL, 
Language.PYTHON))
+                .isEqualTo(ResourceName.ChatModel.Python.WATSONX_SETUP);
+    }
+
     @Test
     void clazzAliasMissPassesThrough() {
         String fqn =
diff --git a/dist/pom.xml b/dist/pom.xml
index 01c24343..7cb74295 100644
--- a/dist/pom.xml
+++ b/dist/pom.xml
@@ -87,6 +87,11 @@ under the License.
             
<artifactId>flink-agents-integrations-chat-models-gemini</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            
<artifactId>flink-agents-integrations-chat-models-watsonx</artifactId>
+            <version>${project.version}</version>
+        </dependency>
         <dependency>
             <groupId>org.apache.flink</groupId>
             
<artifactId>flink-agents-integrations-embedding-models-ollama</artifactId>
diff --git a/docs/content/docs/development/chat_models.md 
b/docs/content/docs/development/chat_models.md
index 8f9962aa..9152136f 100644
--- a/docs/content/docs/development/chat_models.md
+++ b/docs/content/docs/development/chat_models.md
@@ -1241,6 +1241,163 @@ public class MyAgent extends Agent {
 
 A vLLM server serves the model(s) it was started with. Query `GET /v1/models` 
on the server to list them; the `model` value in the setup must match one of 
the returned names.
 
+### Watsonx (IBM watsonx.ai)
+
+IBM watsonx.ai provides cloud-based chat models, including the IBM Granite 
series, with enterprise-grade governance and deployment options on IBM Cloud.
+
+#### Prerequisites
+
+1. Create an account on [IBM Cloud](https://cloud.ibm.com/) and provision a 
[watsonx.ai](https://www.ibm.com/products/watsonx-ai) instance
+2. Create a watsonx.ai project or deployment space
+2. On Developer access, select your Project ID and create API Key
+4. Note the generated API Key, Project ID and watsonx.ai URL for configuring 
your connection
+
+#### WatsonxChatModelConnection Parameters
+
+{{< tabs "WatsonxChatModelConnection Parameters" >}}
+
+{{< tab "Python" >}}
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `url` | str | `$WATSONX_URL` | watsonx.ai service endpoint of your region |
+| `api_key` | str | `$WATSONX_API_KEY` | IBM Cloud API key; configure exactly 
one of `api_key` and `token` |
+| `token` | str | `$WATSONX_TOKEN` | Caller-provided bearer token; it is not 
refreshed automatically and must remain valid for the job lifetime; configure 
exactly one of `api_key` and `token` |
+| `project_id` | str | `$WATSONX_PROJECT_ID` | watsonx.ai project id (or use 
`space_id`) |
+| `space_id` | str | `$WATSONX_SPACE_ID` | Deployment space id, as an 
alternative to `project_id` |
+| `request_timeout` | float | `120.0` | HTTP request timeout in seconds |
+| `max_retries` | int | `3` | Maximum retries for transport failures and HTTP 
408, 429, 500, 502, 503, and 504 responses |
+
+{{< /tab >}}
+
+{{< tab "Java" >}}
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `url` | String | `$WATSONX_URL` | watsonx.ai service endpoint of your region 
|
+| `api_key` | String | `$WATSONX_API_KEY` | IBM Cloud API key; configure 
exactly one of `api_key` and `token` |
+| `token` | String | `$WATSONX_TOKEN` | Caller-provided bearer token; it is 
not refreshed automatically and must remain valid for the job lifetime; 
configure exactly one of `api_key` and `token` |
+| `project_id` | String | `$WATSONX_PROJECT_ID` | watsonx.ai project id (or 
use `space_id`) |
+| `space_id` | String | `$WATSONX_SPACE_ID` | Deployment space id, as an 
alternative to `project_id` |
+| `api_version` | String | `"2025-04-23"` | watsonx.ai REST API version date |
+| `iam_url` | String | `"https://iam.cloud.ibm.com"` | IAM endpoint used to 
exchange the API key for a token |
+| `request_timeout` | double | `120.0` | HTTP request timeout in seconds |
+| `max_retries` | int | `3` | Maximum retries for transport failures and HTTP 
408, 429, 500, 502, 503, and 504 responses |
+
+{{< /tab >}}
+
+{{< /tabs >}}
+
+#### WatsonxChatModelSetup Parameters
+
+{{< tabs "WatsonxChatModelSetup Parameters" >}}
+
+{{< tab "Python" >}}
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `connection` | str | Required | Reference to connection method name |
+| `model` | str | `"ibm/granite-4-h-small"` | watsonx.ai model id to use |
+| `prompt` | Prompt \| str | None | Prompt template or reference to prompt 
resource |
+| `tools` | List[str] | None | List of tool names available to the model |
+| `temperature` | float | `0.1` | Sampling temperature (0.0 to 2.0) |
+| `max_tokens` | int | None | Maximum number of tokens to generate |
+| `extract_reasoning` | bool | `False` | Extract reasoning content (e.g. 
`<think>` blocks) from response |
+| `additional_kwargs` | dict | `{}` | Additional watsonx.ai chat parameters 
(e.g. `top_p`, `time_limit`, `seed`) |
+
+{{< /tab >}}
+
+{{< tab "Java" >}}
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `connection` | String | Required | Reference to connection method name |
+| `model` | String | `"ibm/granite-4-h-small"` | watsonx.ai model id to use |
+| `prompt` | Prompt \| String | None | Prompt template or reference to prompt 
resource |
+| `tools` | List[String] | None | List of tool names available to the model |
+| `temperature` | Double | `0.1` | Sampling temperature (0.0 to 2.0) |
+| `max_tokens` | Integer | None | Maximum number of tokens to generate |
+| `extract_reasoning` | Boolean | `false` | Extract reasoning content (e.g. 
`<think>` blocks) from response |
+| `additional_kwargs` | Map | `{}` | Additional watsonx.ai chat parameters 
(e.g. `top_p`, `time_limit`, `seed`) |
+
+{{< /tab >}}
+
+{{< /tabs >}}
+
+#### Usage Example
+
+{{< tabs "Watsonx Usage Example" >}}
+
+{{< tab "Python" >}}
+```python
+class MyAgent(Agent):
+
+    @chat_model_connection
+    @staticmethod
+    def watsonx_connection() -> ResourceDescriptor:
+        return ResourceDescriptor(
+            clazz=ResourceName.ChatModel.WATSONX_CONNECTION,
+            url="https://us-south.ml.cloud.ibm.com";,  # set WATSONX_URL env var
+            api_key="your-api-key-here",  # set WATSONX_API_KEY env var
+            project_id="your-project-id",  # set WATSONX_PROJECT_ID env var
+        )
+
+    @chat_model_setup
+    @staticmethod
+    def watsonx_chat_model() -> ResourceDescriptor:
+        return ResourceDescriptor(
+            clazz=ResourceName.ChatModel.WATSONX_SETUP,
+            connection="watsonx_connection",
+            model="ibm/granite-4-h-small",
+            temperature=0.1,
+            max_tokens=1024
+        )
+
+    ...
+```
+{{< /tab >}}
+
+{{< tab "Java" >}}
+```java
+public class MyAgent extends Agent {
+    @ChatModelConnection
+    public static ResourceDescriptor watsonxConnection() {
+        return 
ResourceDescriptor.Builder.newBuilder(ResourceName.ChatModel.WATSONX_CONNECTION)
+                .addInitialArgument("url", "https://us-south.ml.cloud.ibm.com";)
+                .addInitialArgument("api_key", "your-api-key-here")
+                .addInitialArgument("project_id", "your-project-id")
+                .build();
+    }
+
+    @ChatModelSetup
+    public static ResourceDescriptor watsonxChatModel() {
+        return 
ResourceDescriptor.Builder.newBuilder(ResourceName.ChatModel.WATSONX_SETUP)
+                .addInitialArgument("connection", "watsonxConnection")
+                .addInitialArgument("model", "ibm/granite-4-h-small")
+                .addInitialArgument("temperature", 0.1)
+                .build();
+    }
+
+    ...
+}
+```
+{{< /tab >}}
+
+{{< /tabs >}}
+
+#### Available Models
+
+Visit the [watsonx.ai foundation models 
documentation](https://www.ibm.com/products/watsonx-ai/foundation-models) for 
the complete and up-to-date list of available chat models.
+
+Some popular options include:
+- **ibm/granite** series (ibm/granite-4-h-small, ibm/granite-3-3-8b-instruct)
+- **meta-llama** series (meta-llama/llama-3-3-70b-instruct)
+- **mistralai** series (mistralai/mistral-large)
+
+{{< hint warning >}}
+Model availability and specifications may change. Always check the official 
IBM watsonx.ai documentation for the latest information before implementing in 
production.
+{{< /hint >}}
+
 ## Using Cross-Language Providers
 
 Flink Agents supports cross-language chat model integration, allowing you to 
use chat models implemented in one language (Java or Python) from agents 
written in the other language. This is particularly useful when a chat model 
provider is only available in one language (e.g., Tongyi is currently 
Python-only).
diff --git a/docs/content/docs/development/yaml.md 
b/docs/content/docs/development/yaml.md
index b4837566..a053873b 100644
--- a/docs/content/docs/development/yaml.md
+++ b/docs/content/docs/development/yaml.md
@@ -558,6 +558,7 @@ Common chat-model aliases:
 | `bedrock`            | —                           | Bedrock (Java)          
    |
 | `tongyi`             | Tongyi (Python)             | —                       
    |
 | `vllm`               | vLLM (Python)               | vLLM (Java)             
    |
+| `watsonx`            | IBM watsonx.ai (Python)     | IBM watsonx.ai (Java)   
    |
 
 Embedding-model aliases (apply to both `embedding_model_connections` and 
`embedding_model_setups`):
 
diff --git a/docs/content/docs/faq/faq.md b/docs/content/docs/faq/faq.md
index 2e0159a0..3f4fe812 100644
--- a/docs/content/docs/faq/faq.md
+++ b/docs/content/docs/faq/faq.md
@@ -105,6 +105,7 @@ Flink Agents provides built-in integrations for many 
ecosystem providers. Some i
 | [OpenAI]({{< ref "docs/development/chat_models#openai" >}}) | ✅ | ✅ |
 | [Tongyi (DashScope)]({{< ref "docs/development/chat_models#tongyi-dashscope" 
>}}) | ✅ | ❌ |
 | [vLLM]({{< ref "docs/development/chat_models#vllm" >}}) | ✅ | ✅ |
+| [Watsonx (IBM watsonx.ai)]({{< ref 
"docs/development/chat_models#watsonx-ibm-watsonxai" >}}) | ✅ | ✅ |
 
 **Embedding Models**
 
diff --git a/integrations/chat-models/pom.xml b/integrations/chat-models/pom.xml
index d83457ae..37840ae7 100644
--- a/integrations/chat-models/pom.xml
+++ b/integrations/chat-models/pom.xml
@@ -36,6 +36,7 @@ under the License.
         <module>gemini</module>
         <module>ollama</module>
         <module>openai</module>
+        <module>watsonx</module>
     </modules>
 
 </project>
diff --git a/integrations/chat-models/watsonx/pom.xml 
b/integrations/chat-models/watsonx/pom.xml
new file mode 100644
index 00000000..864426ff
--- /dev/null
+++ b/integrations/chat-models/watsonx/pom.xml
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0";
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.flink</groupId>
+        <artifactId>flink-agents-integrations-chat-models</artifactId>
+        <version>0.4-SNAPSHOT</version>
+        <relativePath>../pom.xml</relativePath>
+    </parent>
+
+    <artifactId>flink-agents-integrations-chat-models-watsonx</artifactId>
+    <name>Flink Agents : Integrations: Chat Models: IBM watsonx.ai</name>
+    <packaging>jar</packaging>
+
+    <!-- Talks to the watsonx.ai REST API with the JDK HttpClient. The official
+         IBM watsonx.ai Java SDK requires Java 17+, while this project targets
+         Java 11. -->
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-agents-api</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-annotations</artifactId>
+            <version>${flink.version}</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+
+</project>
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
new file mode 100644
index 00000000..627ccd96
--- /dev/null
+++ 
b/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnection.java
@@ -0,0 +1,711 @@
+/*
+ * 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.watsonx;
+
+import com.fasterxml.jackson.core.json.JsonReadFeature;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.tools.Tool;
+import org.apache.flink.annotation.VisibleForTesting;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Chat model connection for the IBM watsonx.ai text chat REST API. */
+public class WatsonxChatModelConnection extends BaseChatModelConnection {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(WatsonxChatModelConnection.class);
+
+    static final String DEFAULT_IAM_URL = "https://iam.cloud.ibm.com";;
+    static final String DEFAULT_API_VERSION = "2025-04-23";
+    static final long DEFAULT_REQUEST_TIMEOUT_SEC = 120;
+    static final int DEFAULT_MAX_RETRIES = 3;
+    private static final Set<Integer> RETRYABLE_STATUS_CODES = Set.of(408, 
429, 500, 502, 503, 504);
+
+    private static final Set<String> CONTROL_PARAMS =
+            Set.of(
+                    "model",
+                    "tool_choice",
+                    "tool_choice_option",
+                    "extract_reasoning",
+                    "additional_kwargs");
+    private static final Set<String> REQUEST_OWNED_PARAMS =
+            Set.of("model_id", "messages", "tools", "project_id", "space_id");
+    private static final Set<String> RESERVED_ADDITIONAL_KWARGS =
+            Set.of(
+                    "model",
+                    "model_id",
+                    "messages",
+                    "tools",
+                    "project_id",
+                    "space_id",
+                    "temperature",
+                    "max_tokens",
+                    "extract_reasoning",
+                    "tool_choice",
+                    "tool_choice_option");
+
+    private static final Pattern[] REASONING_PATTERNS = {
+        Pattern.compile("<think>(.*?)</think>", Pattern.DOTALL | 
Pattern.CASE_INSENSITIVE),
+        Pattern.compile("<analysis>(.*?)</analysis>", Pattern.DOTALL | 
Pattern.CASE_INSENSITIVE),
+        Pattern.compile("<reasoning>(.*?)</reasoning>", Pattern.DOTALL | 
Pattern.CASE_INSENSITIVE),
+        Pattern.compile(
+                "```(?:think|reasoning|thought)\\s*\\n(.*?)\\n```",
+                Pattern.DOTALL | Pattern.CASE_INSENSITIVE),
+        Pattern.compile(
+                "(?:^|\\n)Reasoning:\\s*(.*?)(?:\\n{2,}|$)",
+                Pattern.DOTALL | Pattern.CASE_INSENSITIVE),
+    };
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private static final ObjectMapper LENIENT_MAPPER =
+            JsonMapper.builder()
+                    .enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)
+                    .enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)
+                    .build();
+
+    private final String url;
+    private final String apiKey;
+    private final String staticToken;
+    private final String projectId;
+    private final String spaceId;
+    private final String apiVersion;
+    private final String iamUrl;
+    private final Duration requestTimeout;
+    private final int maxRetries;
+
+    private final HttpClient httpClient;
+
+    private transient String cachedIamToken;
+    private transient long iamTokenExpirationEpochSec;
+
+    public WatsonxChatModelConnection(
+            ResourceDescriptor descriptor, ResourceContext resourceContext) {
+        this(descriptor, resourceContext, System::getenv);
+    }
+
+    @VisibleForTesting
+    WatsonxChatModelConnection(
+            ResourceDescriptor descriptor,
+            ResourceContext resourceContext,
+            Function<String, String> environmentLookup) {
+        super(descriptor, resourceContext);
+
+        this.url =
+                trimTrailingSlash(
+                        argumentOrEnv(descriptor, "url", "WATSONX_URL", 
environmentLookup));
+        this.apiKey = argumentOrEnv(descriptor, "api_key", "WATSONX_API_KEY", 
environmentLookup);
+        this.staticToken = argumentOrEnv(descriptor, "token", "WATSONX_TOKEN", 
environmentLookup);
+        this.projectId =
+                argumentOrEnv(descriptor, "project_id", "WATSONX_PROJECT_ID", 
environmentLookup);
+        this.spaceId = argumentOrEnv(descriptor, "space_id", 
"WATSONX_SPACE_ID", environmentLookup);
+
+        String apiVersion = normalize(descriptor.getArgument("api_version"));
+        this.apiVersion = apiVersion != null ? apiVersion : 
DEFAULT_API_VERSION;
+        String iamUrl = normalize(descriptor.getArgument("iam_url"));
+        this.iamUrl = trimTrailingSlash(iamUrl != null ? iamUrl : 
DEFAULT_IAM_URL);
+        Number requestTimeout = descriptor.getArgument("request_timeout");
+        double requestTimeoutSeconds =
+                requestTimeout != null ? requestTimeout.doubleValue() : 
DEFAULT_REQUEST_TIMEOUT_SEC;
+        if (!Double.isFinite(requestTimeoutSeconds) || requestTimeoutSeconds 
<= 0) {
+            throw new IllegalArgumentException("request_timeout must be a 
positive finite number.");
+        }
+        this.requestTimeout =
+                Duration.ofMillis(Math.max(1L, 
Math.round(requestTimeoutSeconds * 1000.0)));
+        Number maxRetries = descriptor.getArgument("max_retries");
+        this.maxRetries =
+                maxRetries != null
+                        ? requireInteger(maxRetries, "max_retries", 0)
+                        : DEFAULT_MAX_RETRIES;
+
+        if (this.url == null || this.url.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "watsonx.ai url is not provided. Please pass the 'url' 
argument or set the"
+                            + " 'WATSONX_URL' environment variable.");
+        }
+        if ((this.apiKey == null || this.apiKey.isEmpty())
+                && (this.staticToken == null || this.staticToken.isEmpty())) {
+            throw new IllegalArgumentException(
+                    "watsonx.ai credentials are not provided. Please pass the 
'api_key' or 'token'"
+                            + " argument, or set the 'WATSONX_API_KEY' or 
'WATSONX_TOKEN'"
+                            + " environment variable.");
+        }
+        if (this.apiKey != null && this.staticToken != null) {
+            throw new IllegalArgumentException(
+                    "watsonx.ai api_key and token cannot both be provided. 
Please configure"
+                            + " exactly one credential source.");
+        }
+        if ((this.projectId == null || this.projectId.isEmpty())
+                && (this.spaceId == null || this.spaceId.isEmpty())) {
+            throw new IllegalArgumentException(
+                    "watsonx.ai project or space is not provided. Please pass 
the 'project_id' or"
+                            + " 'space_id' argument, or set the 
'WATSONX_PROJECT_ID' or"
+                            + " 'WATSONX_SPACE_ID' environment variable.");
+        }
+        if (this.projectId != null
+                && !this.projectId.isEmpty()
+                && this.spaceId != null
+                && !this.spaceId.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "watsonx.ai project and space cannot both be provided. 
Please configure"
+                            + " exactly one of 'project_id' or 'space_id'.");
+        }
+
+        this.httpClient = 
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
+    }
+
+    static int requireInteger(Number value, String argumentName, int minimum) {
+        double numericValue = value.doubleValue();
+        if (!Double.isFinite(numericValue)
+                || numericValue != Math.rint(numericValue)
+                || numericValue < minimum
+                || numericValue > Integer.MAX_VALUE) {
+            throw new IllegalArgumentException(
+                    argumentName
+                            + " must be "
+                            + (minimum == 0 ? "a non-negative" : "a positive")
+                            + " integer.");
+        }
+        return (int) numericValue;
+    }
+
+    private static String argumentOrEnv(
+            ResourceDescriptor descriptor,
+            String argumentName,
+            String envName,
+            Function<String, String> environmentLookup) {
+        String value = normalize(descriptor.getArgument(argumentName));
+        if (value == null) {
+            value = normalize(environmentLookup.apply(envName));
+        }
+        return value;
+    }
+
+    private static String normalize(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    private static String trimTrailingSlash(String value) {
+        if (value != null && value.endsWith("/")) {
+            return value.substring(0, value.length() - 1);
+        }
+        return value;
+    }
+
+    @Override
+    public ChatMessage chat(
+            List<ChatMessage> messages, List<Tool> tools, Map<String, Object> 
modelParams) {
+        try {
+            final String modelName = (String) modelParams.get("model");
+            final boolean extractReasoning =
+                    Boolean.TRUE.equals(modelParams.get("extract_reasoning"));
+            final ObjectNode payload = buildPayload(messages, tools, 
modelParams);
+            if (projectId != null && !projectId.isEmpty()) {
+                payload.put("project_id", projectId);
+            } else {
+                payload.put("space_id", spaceId);
+            }
+
+            final String requestBody = MAPPER.writeValueAsString(payload);
+            String bearerToken = getBearerToken();
+            HttpResponse<String> response =
+                    sendWithRetry(buildChatRequest(requestBody, bearerToken));
+            if ((response.statusCode() == 401 || response.statusCode() == 403) 
&& apiKey != null) {
+                LOG.warn(
+                        "watsonx.ai returned status {}; refreshing the cached 
IAM token and"
+                                + " retrying once",
+                        response.statusCode());
+                invalidateCachedIamToken(bearerToken);
+                bearerToken = getBearerToken();
+                response = sendWithRetry(buildChatRequest(requestBody, 
bearerToken));
+            }
+            if (response.statusCode() / 100 != 2) {
+                throw new RuntimeException(
+                        String.format(
+                                "watsonx.ai chat request failed with status 
%d: %s",
+                                response.statusCode(), response.body()));
+            }
+
+            final ChatMessage chatMessage =
+                    parseResponse(MAPPER.readTree(response.body()), modelName);
+            if (extractReasoning) {
+                final String[] parts = 
extractReasoning(chatMessage.getContent());
+                chatMessage.setContent(parts[0]);
+                if (parts[1] != null) {
+                    chatMessage.getExtraArgs().put("reasoning", parts[1]);
+                }
+            }
+            return chatMessage;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException("Interrupted while calling 
watsonx.ai.", e);
+        } catch (RuntimeException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private HttpRequest buildChatRequest(String requestBody, String 
bearerToken) {
+        return HttpRequest.newBuilder()
+                .uri(URI.create(url + "/ml/v1/text/chat?version=" + 
apiVersion))
+                .timeout(requestTimeout)
+                .header("Authorization", "Bearer " + bearerToken)
+                .header("Content-Type", "application/json")
+                .header("Accept", "application/json")
+                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
+                .build();
+    }
+
+    /**
+     * Sends the request, retrying HTTP 408, 429, 500, 502, 503, and 504 
responses and I/O errors up
+     * to {@code max_retries} times with capped exponential backoff, honoring 
{@code Retry-After}.
+     */
+    private HttpResponse<String> sendWithRetry(HttpRequest request)
+            throws IOException, InterruptedException {
+        for (int attempt = 0; ; attempt++) {
+            try {
+                final HttpResponse<String> response =
+                        httpClient.send(request, 
HttpResponse.BodyHandlers.ofString());
+                if (attempt >= maxRetries || 
!isRetryableStatus(response.statusCode())) {
+                    return response;
+                }
+                final long delayMillis =
+                        retryDelayMillis(
+                                attempt, 
response.headers().firstValue("Retry-After").orElse(null));
+                LOG.warn(
+                        "watsonx.ai request to {} returned status {}; retry 
{}/{} in {} ms",
+                        request.uri().getPath(),
+                        response.statusCode(),
+                        attempt + 1,
+                        maxRetries,
+                        delayMillis);
+                Thread.sleep(delayMillis);
+            } catch (IOException e) {
+                if (attempt >= maxRetries) {
+                    throw e;
+                }
+                final long delayMillis = retryDelayMillis(attempt, null);
+                LOG.warn(
+                        "watsonx.ai request to {} failed ({}); retry {}/{} in 
{} ms",
+                        request.uri().getPath(),
+                        e.toString(),
+                        attempt + 1,
+                        maxRetries,
+                        delayMillis);
+                Thread.sleep(delayMillis);
+            }
+        }
+    }
+
+    @VisibleForTesting
+    static boolean isRetryableStatus(int status) {
+        return RETRYABLE_STATUS_CODES.contains(status);
+    }
+
+    @VisibleForTesting
+    static long retryDelayMillis(int attempt, String retryAfterHeader) {
+        long backoffMillis = Math.min(1000L << attempt, 10_000L);
+        if (retryAfterHeader != null) {
+            try {
+                long retryAfterMillis =
+                        Math.min(Long.parseLong(retryAfterHeader.trim()) * 
1000L, 30_000L);
+                backoffMillis = Math.max(backoffMillis, retryAfterMillis);
+            } catch (NumberFormatException ignored) {
+                // Retry-After may be an HTTP date; fall back to exponential 
backoff.
+            }
+        }
+        return backoffMillis;
+    }
+
+    @VisibleForTesting
+    static String[] extractReasoning(String content) {
+        if (content == null || content.isEmpty()) {
+            return new String[] {"", null};
+        }
+        final List<String> reasoningChunks = new ArrayList<>();
+        String cleaned = content;
+        for (Pattern pattern : REASONING_PATTERNS) {
+            final Matcher matcher = pattern.matcher(cleaned);
+            final StringBuilder rest = new StringBuilder();
+            boolean found = false;
+            int position = 0;
+            while (matcher.find()) {
+                final String chunk = matcher.group(1).trim();
+                if (!chunk.isEmpty()) {
+                    reasoningChunks.add(chunk);
+                }
+                rest.append(cleaned, position, matcher.start());
+                position = matcher.end();
+                found = true;
+            }
+            if (found) {
+                rest.append(cleaned, position, cleaned.length());
+                cleaned = rest.toString();
+            }
+        }
+        if (reasoningChunks.isEmpty()) {
+            return new String[] {content, null};
+        }
+        final String reasoning = String.join("\n\n", reasoningChunks);
+        cleaned = cleaned.replaceAll("\\n{3,}", "\n\n").replaceAll(" {2,}", " 
").trim();
+        return new String[] {cleaned, reasoning};
+    }
+
+    @VisibleForTesting
+    static ObjectNode buildPayload(
+            List<ChatMessage> messages, List<Tool> tools, Map<String, Object> 
modelParams) {
+        final ObjectNode payload = MAPPER.createObjectNode();
+        payload.put("model_id", (String) modelParams.get("model"));
+        payload.set("messages", convertMessages(messages));
+
+        if (tools != null && !tools.isEmpty()) {
+            payload.set("tools", convertTools(tools));
+        }
+        final Object toolChoice = modelParams.get("tool_choice");
+        if (toolChoice != null) {
+            payload.set("tool_choice", MAPPER.valueToTree(toolChoice));
+        }
+        final Object toolChoiceOption = modelParams.get("tool_choice_option");
+        if (toolChoiceOption != null) {
+            payload.put("tool_choice_option", toolChoiceOption.toString());
+        }
+
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> additionalKwargs =
+                (Map<String, Object>) modelParams.get("additional_kwargs");
+        if (additionalKwargs != null) {
+            final Set<String> collisions = new 
java.util.HashSet<>(additionalKwargs.keySet());
+            collisions.retainAll(RESERVED_ADDITIONAL_KWARGS);
+            if (!collisions.isEmpty()) {
+                throw new IllegalArgumentException(
+                        "additional_kwargs must not contain reserved typed 
fields: "
+                                + collisions
+                                + ". Set these via the corresponding Setup 
field instead.");
+            }
+            additionalKwargs.forEach(
+                    (key, value) -> {
+                        if (value != null) {
+                            payload.set(key, MAPPER.valueToTree(value));
+                        }
+                    });
+        }
+
+        final Set<String> collisions = new 
java.util.HashSet<>(modelParams.keySet());
+        collisions.retainAll(REQUEST_OWNED_PARAMS);
+        if (!collisions.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "model parameters must not contain framework-owned fields: 
" + collisions);
+        }
+        for (Map.Entry<String, Object> entry : modelParams.entrySet()) {
+            if (!CONTROL_PARAMS.contains(entry.getKey()) && entry.getValue() 
!= null) {
+                payload.set(entry.getKey(), 
MAPPER.valueToTree(entry.getValue()));
+            }
+        }
+        return payload;
+    }
+
+    /**
+     * Converts framework chat messages to the watsonx.ai (OpenAI-compatible) 
message format.
+     *
+     * <ul>
+     *   <li>SYSTEM/USER messages carry {@code role} and {@code content}.
+     *   <li>ASSISTANT messages may carry {@code tool_calls} with JSON string 
arguments.
+     *   <li>TOOL messages carry {@code tool_call_id} referencing the original 
call, taken from the
+     *       {@code externalId} entry of the message extra args.
+     * </ul>
+     */
+    @VisibleForTesting
+    static ArrayNode convertMessages(List<ChatMessage> messages) {
+        final ArrayNode result = MAPPER.createArrayNode();
+        for (ChatMessage message : messages) {
+            final ObjectNode node = MAPPER.createObjectNode();
+            final MessageRole role = message.getRole();
+            switch (role) {
+                case SYSTEM:
+                case USER:
+                    node.put("role", role.name().toLowerCase());
+                    node.put("content", message.getContent());
+                    break;
+                case ASSISTANT:
+                    node.put("role", "assistant");
+                    if (message.getContent() != null && 
!message.getContent().isEmpty()) {
+                        node.put("content", message.getContent());
+                    }
+                    final List<Map<String, Object>> toolCalls = 
message.getToolCalls();
+                    if (toolCalls != null && !toolCalls.isEmpty()) {
+                        node.set("tool_calls", convertToolCalls(toolCalls));
+                    }
+                    break;
+                case TOOL:
+                    final Object externalId = 
message.getExtraArgs().get("externalId");
+                    if (externalId == null) {
+                        throw new IllegalArgumentException(
+                                "Tool message must have 'externalId' in extra 
args.");
+                    }
+                    node.put("role", "tool");
+                    node.put("content", message.getContent());
+                    node.put("tool_call_id", externalId.toString());
+                    break;
+                default:
+                    throw new IllegalArgumentException("Unsupported role: " + 
role);
+            }
+            result.add(node);
+        }
+        return result;
+    }
+
+    private static ArrayNode convertToolCalls(List<Map<String, Object>> 
toolCalls) {
+        final ArrayNode result = MAPPER.createArrayNode();
+        for (Map<String, Object> toolCall : toolCalls) {
+            final Object originalId = toolCall.get("original_id");
+            final Object id = originalId != null ? originalId : 
toolCall.get("id");
+            if (id == null) {
+                throw new IllegalArgumentException(
+                        "Tool call must have either 'original_id' or 'id' 
field.");
+            }
+
+            @SuppressWarnings("unchecked")
+            final Map<String, Object> function = (Map<String, Object>) 
toolCall.get("function");
+            final Object arguments = function.get("arguments");
+            final String argumentsJson;
+            try {
+                argumentsJson =
+                        arguments instanceof String
+                                ? (String) arguments
+                                : MAPPER.writeValueAsString(arguments);
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+
+            final ObjectNode node = MAPPER.createObjectNode();
+            node.put("id", id.toString());
+            node.put("type", "function");
+            final ObjectNode functionNode = node.putObject("function");
+            functionNode.put("name", (String) function.get("name"));
+            functionNode.put("arguments", argumentsJson);
+            result.add(node);
+        }
+        return result;
+    }
+
+    @VisibleForTesting
+    static ArrayNode convertTools(List<Tool> tools) {
+        final ArrayNode result = MAPPER.createArrayNode();
+        try {
+            for (Tool tool : tools) {
+                final ObjectNode node = MAPPER.createObjectNode();
+                node.put("type", "function");
+                final ObjectNode functionNode = node.putObject("function");
+                functionNode.put("name", tool.getName());
+                functionNode.put("description", tool.getDescription());
+                functionNode.set(
+                        "parameters", 
MAPPER.readTree(tool.getMetadata().getInputSchema()));
+                result.add(node);
+            }
+            return result;
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @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");
+        if (finishReasonNode != null && !finishReasonNode.isNull()) {
+            final String finishReason = finishReasonNode.asText();
+            if (!"stop".equals(finishReason) && 
!"tool_calls".equals(finishReason)) {
+                LOG.warn(
+                        "watsonx.ai chat for model {} finished with reason 
'{}'; the response"
+                                + " may be truncated or incomplete",
+                        modelName,
+                        finishReason);
+            }
+        }
+
+        final JsonNode contentNode = responseMessage.get("content");
+        final String content =
+                contentNode != null && !contentNode.isNull() ? 
contentNode.asText() : "";
+        final ChatMessage chatMessage = ChatMessage.assistant(content);
+
+        final JsonNode toolCallsNode = responseMessage.get("tool_calls");
+        if (toolCallsNode != null && toolCallsNode.isArray() && 
!toolCallsNode.isEmpty()) {
+            final List<Map<String, Object>> toolCalls = new 
java.util.ArrayList<>();
+            for (JsonNode toolCallNode : toolCallsNode) {
+                final String id = toolCallNode.required("id").asText();
+                final JsonNode functionNode = 
toolCallNode.required("function");
+                final Map<String, Object> arguments =
+                        parseToolArguments(functionNode.get("arguments"));
+                toolCalls.add(
+                        Map.of(
+                                "id",
+                                id,
+                                "original_id",
+                                id,
+                                "type",
+                                "function",
+                                "function",
+                                Map.of(
+                                        "name",
+                                        functionNode.required("name").asText(),
+                                        "arguments",
+                                        arguments)));
+            }
+            chatMessage.setToolCalls(toolCalls);
+        }
+
+        final JsonNode usage = response.get("usage");
+        if (modelName != null && !modelName.isBlank() && usage != null && 
!usage.isNull()) {
+            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));
+            chatMessage.setExtraArgs(extraArgs);
+        }
+
+        return chatMessage;
+    }
+
+    /**
+     * Parses model-emitted tool call arguments into a map, which is the 
format the framework's tool
+     * execution expects.
+     *
+     * <p>Models do not always return arguments as a clean JSON object string: 
some double-encode
+     * the JSON, and some (notably smaller models) emit single-quoted or 
unquoted pseudo-JSON. This
+     * method tolerates those variants and throws a descriptive error 
(including the raw value) when
+     * the arguments cannot be interpreted as an object.
+     */
+    @VisibleForTesting
+    static Map<String, Object> parseToolArguments(JsonNode argumentsNode) {
+        if (argumentsNode == null || argumentsNode.isNull()) {
+            return Map.of();
+        }
+        if (argumentsNode.isObject()) {
+            return MAPPER.convertValue(argumentsNode, new 
TypeReference<Map<String, Object>>() {});
+        }
+        if (argumentsNode.isTextual()) {
+            String text = argumentsNode.asText().trim();
+            if (text.isEmpty()) {
+                return Map.of();
+            }
+            // Unwrap up to a few levels of string-encoding ("{\"a\": 1}" or 
"\"{\\\"a\\\": 1}\"").
+            for (int i = 0; i < 3; i++) {
+                final JsonNode parsed;
+                try {
+                    parsed = LENIENT_MAPPER.readTree(text);
+                } catch (Exception e) {
+                    break;
+                }
+                if (parsed.isObject()) {
+                    return MAPPER.convertValue(parsed, new 
TypeReference<Map<String, Object>>() {});
+                }
+                if (parsed.isTextual()) {
+                    text = parsed.asText().trim();
+                    continue;
+                }
+                break;
+            }
+        }
+        throw new RuntimeException(
+                "Failed to parse tool call arguments returned by watsonx.ai as 
a JSON object: "
+                        + argumentsNode);
+    }
+
+    private synchronized String getBearerToken() {
+        if (staticToken != null && !staticToken.isEmpty()) {
+            return staticToken;
+        }
+        final long nowEpochSec = System.currentTimeMillis() / 1000;
+        // Refresh 60 seconds before the cached token expires.
+        if (cachedIamToken != null && nowEpochSec < iamTokenExpirationEpochSec 
- 60) {
+            return cachedIamToken;
+        }
+
+        try {
+            final String form =
+                    
"grant_type=urn%3Aibm%3Aparams%3Aoauth%3Agrant-type%3Aapikey&apikey="
+                            + URLEncoder.encode(apiKey, 
StandardCharsets.UTF_8);
+            final HttpRequest request =
+                    HttpRequest.newBuilder()
+                            .uri(URI.create(iamUrl + "/identity/token"))
+                            .timeout(Duration.ofSeconds(30))
+                            .header("Content-Type", 
"application/x-www-form-urlencoded")
+                            .header("Accept", "application/json")
+                            .POST(HttpRequest.BodyPublishers.ofString(form))
+                            .build();
+            final HttpResponse<String> response = sendWithRetry(request);
+            if (response.statusCode() / 100 != 2) {
+                throw new RuntimeException(
+                        String.format(
+                                "IAM token request failed with status %d: %s",
+                                response.statusCode(), response.body()));
+            }
+            final JsonNode tokenResponse = MAPPER.readTree(response.body());
+            cachedIamToken = tokenResponse.required("access_token").asText();
+            iamTokenExpirationEpochSec =
+                    nowEpochSec + 
tokenResponse.path("expires_in").asLong(3600);
+            return cachedIamToken;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException("Interrupted while obtaining a 
watsonx.ai IAM token.", e);
+        } catch (RuntimeException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private synchronized void invalidateCachedIamToken(String rejectedToken) {
+        if (rejectedToken.equals(cachedIamToken)) {
+            cachedIamToken = null;
+            iamTokenExpirationEpochSec = 0;
+        }
+    }
+}
diff --git 
a/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelSetup.java
 
b/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelSetup.java
new file mode 100644
index 00000000..01df5b09
--- /dev/null
+++ 
b/integrations/chat-models/watsonx/src/main/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelSetup.java
@@ -0,0 +1,75 @@
+/*
+ * 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.watsonx;
+
+import org.apache.flink.agents.api.chat.model.BaseChatModelSetup;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/** Chat model setup for IBM watsonx.ai. */
+public class WatsonxChatModelSetup extends BaseChatModelSetup {
+
+    public static final String DEFAULT_MODEL = "ibm/granite-4-h-small";
+    public static final double DEFAULT_TEMPERATURE = 0.1;
+
+    private final Double temperature;
+    private final Integer maxTokens;
+    private final boolean extractReasoning;
+    private final Map<String, Object> additionalKwargs;
+
+    public WatsonxChatModelSetup(ResourceDescriptor descriptor, 
ResourceContext resourceContext) {
+        super(descriptor, resourceContext);
+        if (this.model == null || this.model.isEmpty()) {
+            this.model = DEFAULT_MODEL;
+        }
+        Number temperature = descriptor.getArgument("temperature");
+        this.temperature = temperature != null ? temperature.doubleValue() : 
DEFAULT_TEMPERATURE;
+        if (!Double.isFinite(this.temperature)
+                || this.temperature < 0.0
+                || this.temperature > 2.0) {
+            throw new IllegalArgumentException(
+                    "temperature must be a finite number between 0.0 and 2.0");
+        }
+        Number maxTokens = descriptor.getArgument("max_tokens");
+        this.maxTokens =
+                maxTokens != null
+                        ? WatsonxChatModelConnection.requireInteger(maxTokens, 
"max_tokens", 1)
+                        : null;
+        this.extractReasoning = 
Boolean.TRUE.equals(descriptor.getArgument("extract_reasoning"));
+        Map<String, Object> additionalKwargs = 
descriptor.getArgument("additional_kwargs");
+        this.additionalKwargs = additionalKwargs != null ? additionalKwargs : 
Map.of();
+    }
+
+    @Override
+    public Map<String, Object> getParameters() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("model", model);
+        params.put("temperature", temperature);
+        params.put("extract_reasoning", extractReasoning);
+        if (maxTokens != null) {
+            params.put("max_tokens", maxTokens);
+        }
+        if (!additionalKwargs.isEmpty()) {
+            params.put("additional_kwargs", additionalKwargs);
+        }
+        return params;
+    }
+}
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
new file mode 100644
index 00000000..9b1cd5b6
--- /dev/null
+++ 
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelConnectionTest.java
@@ -0,0 +1,617 @@
+/*
+ * 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.watsonx;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link WatsonxChatModelConnection}. Request-level tests use 
a local stub server,
+ * so the suite runs in CI without external network access or an API key.
+ */
+class WatsonxChatModelConnectionTest {
+
+    private static final ResourceContext NOOP = 
ResourceContext.fromGetResource((a, b) -> null);
+    private static final Function<String, String> NO_ENVIRONMENT = ignored -> 
null;
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private static final String CHAT_RESPONSE =
+            "{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\","
+                    + "\"content\":\"Hello!\"},\"finish_reason\":\"stop\"}]}";
+
+    private static ResourceDescriptor descriptor(String url, String apiKey, 
String projectId) {
+        ResourceDescriptor.Builder b =
+                
ResourceDescriptor.Builder.newBuilder(WatsonxChatModelConnection.class.getName());
+        if (url != null) {
+            b.addInitialArgument("url", url);
+        }
+        if (apiKey != null) {
+            b.addInitialArgument("api_key", apiKey);
+        }
+        if (projectId != null) {
+            b.addInitialArgument("project_id", projectId);
+        }
+        return b.build();
+    }
+
+    private static ResourceDescriptor stubDescriptor(
+            String baseUrl, boolean useApiKey, int maxRetries) {
+        ResourceDescriptor.Builder builder =
+                
ResourceDescriptor.Builder.newBuilder(WatsonxChatModelConnection.class.getName())
+                        .addInitialArgument("url", baseUrl)
+                        .addInitialArgument("project_id", "test-project")
+                        .addInitialArgument("max_retries", maxRetries);
+        if (useApiKey) {
+            builder.addInitialArgument("api_key", "test-key");
+            builder.addInitialArgument("iam_url", baseUrl);
+        } else {
+            builder.addInitialArgument("token", "test-token");
+        }
+        return builder.build();
+    }
+
+    private static HttpServer startServer() throws IOException {
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        server.setExecutor(null);
+        server.start();
+        return server;
+    }
+
+    private static String baseUrl(HttpServer server) {
+        return "http://127.0.0.1:"; + server.getAddress().getPort();
+    }
+
+    private static void sendJson(HttpExchange exchange, int status, String 
body)
+            throws IOException {
+        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+        exchange.getResponseHeaders().add("Content-Type", "application/json");
+        exchange.sendResponseHeaders(status, bytes.length);
+        exchange.getResponseBody().write(bytes);
+        exchange.close();
+    }
+
+    private static ChatMessage chat(WatsonxChatModelConnection connection) {
+        return connection.chat(
+                List.of(new ChatMessage(MessageRole.USER, "Hello!")),
+                List.of(),
+                Map.of("model", "ibm/granite-3-3-8b-instruct"));
+    }
+
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("missingRequiredConfiguration")
+    void testConstructorRejectsMissingRequiredConfiguration(
+            String ignoredCaseName,
+            String url,
+            String apiKey,
+            String projectId,
+            String expectedMessage) {
+        assertThatThrownBy(
+                        () ->
+                                new WatsonxChatModelConnection(
+                                        descriptor(url, apiKey, projectId), 
NOOP, NO_ENVIRONMENT))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(expectedMessage);
+    }
+
+    private static Stream<Arguments> missingRequiredConfiguration() {
+        return Stream.of(
+                Arguments.of("missing url", null, "test-key", "test-project", 
"url"),
+                Arguments.of(
+                        "missing credentials",
+                        "https://us-south.ml.cloud.ibm.com";,
+                        null,
+                        "test-project",
+                        "credentials"),
+                Arguments.of(
+                        "missing project or space",
+                        "https://us-south.ml.cloud.ibm.com";,
+                        "test-key",
+                        null,
+                        "project or space"));
+    }
+
+    @Test
+    @DisplayName("Constructor accepts space_id without project_id")
+    void testConstructorWithSpaceId() {
+        ResourceDescriptor descriptor =
+                
ResourceDescriptor.Builder.newBuilder(WatsonxChatModelConnection.class.getName())
+                        .addInitialArgument("url", " 
https://us-south.ml.cloud.ibm.com ")
+                        .addInitialArgument("api_key", " test-key ")
+                        .addInitialArgument("space_id", " test-space ")
+                        .build();
+
+        assertThat(new WatsonxChatModelConnection(descriptor, NOOP, 
NO_ENVIRONMENT))
+                .isInstanceOf(BaseChatModelConnection.class);
+    }
+
+    @Test
+    @DisplayName("Constructor rejects ambiguous scope and credentials")
+    void testConstructorRejectsAmbiguousConfiguration() {
+        ResourceDescriptor descriptor =
+                
ResourceDescriptor.Builder.newBuilder(WatsonxChatModelConnection.class.getName())
+                        .addInitialArgument("url", 
"https://us-south.ml.cloud.ibm.com";)
+                        .addInitialArgument("api_key", "test-key")
+                        .addInitialArgument("project_id", "test-project")
+                        .addInitialArgument("space_id", "test-space")
+                        .build();
+
+        assertThatThrownBy(() -> new WatsonxChatModelConnection(descriptor, 
NOOP, NO_ENVIRONMENT))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("cannot both be provided")
+                .hasMessageContaining("exactly one");
+
+        ResourceDescriptor credentials =
+                
ResourceDescriptor.Builder.newBuilder(WatsonxChatModelConnection.class.getName())
+                        .addInitialArgument("url", " 
https://us-south.ml.cloud.ibm.com ")
+                        .addInitialArgument("api_key", " test-key ")
+                        .addInitialArgument("token", " test-token ")
+                        .addInitialArgument("project_id", " test-project ")
+                        .build();
+        assertThatThrownBy(() -> new WatsonxChatModelConnection(credentials, 
NOOP, NO_ENVIRONMENT))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("api_key and token")
+                .hasMessageContaining("exactly one");
+    }
+
+    @Test
+    @DisplayName("Request timeout accepts positive fractional seconds and 
rejects invalid values")
+    void testRequestTimeoutValidation() {
+        ResourceDescriptor fractionalTimeout =
+                
ResourceDescriptor.Builder.newBuilder(WatsonxChatModelConnection.class.getName())
+                        .addInitialArgument("url", 
"https://us-south.ml.cloud.ibm.com";)
+                        .addInitialArgument("api_key", "test-key")
+                        .addInitialArgument("project_id", "test-project")
+                        .addInitialArgument("request_timeout", 0.5)
+                        .build();
+        assertThat(new WatsonxChatModelConnection(fractionalTimeout, NOOP, 
NO_ENVIRONMENT))
+                .isInstanceOf(BaseChatModelConnection.class);
+
+        for (double invalidTimeout : List.of(0.0, -1.0, Double.NaN, 
Double.POSITIVE_INFINITY)) {
+            ResourceDescriptor invalid =
+                    ResourceDescriptor.Builder.newBuilder(
+                                    WatsonxChatModelConnection.class.getName())
+                            .addInitialArgument("url", 
"https://us-south.ml.cloud.ibm.com";)
+                            .addInitialArgument("api_key", "test-key")
+                            .addInitialArgument("project_id", "test-project")
+                            .addInitialArgument("request_timeout", 
invalidTimeout)
+                            .build();
+            assertThatThrownBy(() -> new WatsonxChatModelConnection(invalid, 
NOOP, NO_ENVIRONMENT))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("request_timeout");
+        }
+    }
+
+    @Test
+    @DisplayName("max_retries must be a non-negative integer")
+    void testMaxRetriesValidation() {
+        for (Number invalidMaxRetries :
+                new Number[] {-1, 0.9, Double.NaN, Double.POSITIVE_INFINITY}) {
+            ResourceDescriptor invalid =
+                    ResourceDescriptor.Builder.newBuilder(
+                                    WatsonxChatModelConnection.class.getName())
+                            .addInitialArgument("url", 
"https://us-south.ml.cloud.ibm.com";)
+                            .addInitialArgument("api_key", "test-key")
+                            .addInitialArgument("project_id", "test-project")
+                            .addInitialArgument("max_retries", 
invalidMaxRetries)
+                            .build();
+            assertThatThrownBy(() -> new WatsonxChatModelConnection(invalid, 
NOOP, NO_ENVIRONMENT))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("max_retries");
+        }
+    }
+
+    @Test
+    @DisplayName("System, user, assistant and tool messages convert to the 
watsonx format")
+    void testConvertMessages() {
+        ChatMessage assistant = new ChatMessage(MessageRole.ASSISTANT, "");
+        assistant.setToolCalls(
+                List.of(
+                        Map.of(
+                                "id", "internal-uuid",
+                                "original_id", "call_abc123",
+                                "type", "function",
+                                "function",
+                                        Map.of(
+                                                "name",
+                                                "add",
+                                                "arguments",
+                                                Map.of("a", 1, "b", 2)))));
+        ChatMessage toolResult =
+                new ChatMessage(MessageRole.TOOL, "3", Map.of("externalId", 
"call_abc123"));
+
+        ArrayNode converted =
+                WatsonxChatModelConnection.convertMessages(
+                        List.of(
+                                new ChatMessage(MessageRole.SYSTEM, "You are 
helpful."),
+                                new ChatMessage(MessageRole.USER, "What is 1 + 
2?"),
+                                assistant,
+                                toolResult));
+
+        assertThat(converted).hasSize(4);
+        assertThat(converted.get(0).get("role").asText()).isEqualTo("system");
+        assertThat(converted.get(0).get("content").asText()).isEqualTo("You 
are helpful.");
+        assertThat(converted.get(1).get("role").asText()).isEqualTo("user");
+
+        JsonNode assistantNode = converted.get(2);
+        assertThat(assistantNode.get("role").asText()).isEqualTo("assistant");
+        assertThat(assistantNode.has("content")).isFalse();
+        JsonNode toolCall = assistantNode.get("tool_calls").get(0);
+        assertThat(toolCall.get("id").asText()).isEqualTo("call_abc123");
+        
assertThat(toolCall.get("function").get("name").asText()).isEqualTo("add");
+        // arguments must be serialized as a JSON string
+        
assertThat(toolCall.get("function").get("arguments").isTextual()).isTrue();
+
+        JsonNode toolNode = converted.get(3);
+        assertThat(toolNode.get("role").asText()).isEqualTo("tool");
+        
assertThat(toolNode.get("tool_call_id").asText()).isEqualTo("call_abc123");
+        assertThat(toolNode.get("content").asText()).isEqualTo("3");
+    }
+
+    @Test
+    @DisplayName("Tool message without externalId is rejected")
+    void testConvertToolMessageWithoutExternalId() {
+        assertThatThrownBy(
+                        () ->
+                                WatsonxChatModelConnection.convertMessages(
+                                        List.of(new 
ChatMessage(MessageRole.TOOL, "3"))))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("externalId");
+    }
+
+    @Test
+    @DisplayName("Model params are copied top-level into the payload")
+    void testBuildPayload() {
+        ObjectNode payload =
+                WatsonxChatModelConnection.buildPayload(
+                        List.of(new ChatMessage(MessageRole.USER, "Hello!")),
+                        List.of(),
+                        Map.of(
+                                "model",
+                                "ibm/granite-3-3-8b-instruct",
+                                "temperature",
+                                0.5,
+                                "max_tokens",
+                                256,
+                                "top_p",
+                                0.5,
+                                "extract_reasoning",
+                                true,
+                                "additional_kwargs",
+                                Map.of("top_p", 0.9)));
+
+        
assertThat(payload.get("model_id").asText()).isEqualTo("ibm/granite-3-3-8b-instruct");
+        assertThat(payload.get("temperature").asDouble()).isEqualTo(0.5);
+        assertThat(payload.get("max_tokens").asInt()).isEqualTo(256);
+        assertThat(payload.get("top_p").asDouble()).isEqualTo(0.5);
+        assertThat(payload.get("messages")).hasSize(1);
+        // framework control params must not leak into the request
+        assertThat(payload.has("model")).isFalse();
+        assertThat(payload.has("extract_reasoning")).isFalse();
+        assertThat(payload.has("tools")).isFalse();
+
+        assertThatThrownBy(
+                        () ->
+                                WatsonxChatModelConnection.buildPayload(
+                                        List.of(new 
ChatMessage(MessageRole.USER, "Hello!")),
+                                        List.of(),
+                                        Map.of(
+                                                "model",
+                                                "ibm/granite-3-3-8b-instruct",
+                                                "additional_kwargs",
+                                                Map.of("temperature", 5.0))))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("additional_kwargs")
+                .hasMessageContaining("temperature");
+
+        for (String requestOwnedField :
+                List.of("model_id", "messages", "tools", "project_id", 
"space_id")) {
+            assertThatThrownBy(
+                            () ->
+                                    WatsonxChatModelConnection.buildPayload(
+                                            List.of(new 
ChatMessage(MessageRole.USER, "Hello!")),
+                                            List.of(),
+                                            Map.of(
+                                                    "model",
+                                                    
"ibm/granite-3-3-8b-instruct",
+                                                    requestOwnedField,
+                                                    "override")))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining(requestOwnedField);
+        }
+    }
+
+    @Test
+    @DisplayName("Chat response with content and usage parses into a 
ChatMessage")
+    void testParseResponse() throws Exception {
+        JsonNode response =
+                MAPPER.readTree(
+                        "{\"choices\": [{\"index\": 0, \"message\": {\"role\": 
\"assistant\","
+                                + " \"content\": \"Hello there!\"}, 
\"finish_reason\": \"stop\"}],"
+                                + " \"usage\": {\"prompt_tokens\": 100, 
\"completion_tokens\": 50,"
+                                + " \"total_tokens\": 150}}");
+
+        ChatMessage message =
+                WatsonxChatModelConnection.parseResponse(response, 
"ibm/granite-3-3-8b-instruct");
+
+        assertThat(message.getRole()).isEqualTo(MessageRole.ASSISTANT);
+        assertThat(message.getContent()).isEqualTo("Hello there!");
+        assertThat(message.getExtraArgs().get("model_name"))
+                .isEqualTo("ibm/granite-3-3-8b-instruct");
+        assertThat(message.getExtraArgs().get("promptTokens")).isEqualTo(100L);
+        
assertThat(message.getExtraArgs().get("completionTokens")).isEqualTo(50L);
+    }
+
+    @Test
+    @DisplayName("IAM token is cached and reused between chat requests")
+    void testIamTokenIsCached() throws Exception {
+        HttpServer server = startServer();
+        AtomicInteger iamRequests = new AtomicInteger();
+        AtomicInteger chatRequests = new AtomicInteger();
+        server.createContext(
+                "/identity/token",
+                exchange -> {
+                    iamRequests.incrementAndGet();
+                    sendJson(exchange, 200, 
"{\"access_token\":\"token-1\",\"expires_in\":3600}");
+                });
+        server.createContext(
+                "/ml/v1/text/chat",
+                exchange -> {
+                    chatRequests.incrementAndGet();
+                    sendJson(exchange, 200, CHAT_RESPONSE);
+                });
+
+        try {
+            WatsonxChatModelConnection connection =
+                    new WatsonxChatModelConnection(
+                            stubDescriptor(baseUrl(server), true, 0), NOOP, 
NO_ENVIRONMENT);
+            assertThat(chat(connection).getContent()).isEqualTo("Hello!");
+            assertThat(chat(connection).getContent()).isEqualTo("Hello!");
+            assertThat(iamRequests).hasValue(1);
+            assertThat(chatRequests).hasValue(2);
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    @DisplayName("IAM token is refreshed inside the 60-second expiry margin")
+    void testIamTokenRefreshMargin() throws Exception {
+        HttpServer server = startServer();
+        AtomicInteger iamRequests = new AtomicInteger();
+        server.createContext(
+                "/identity/token",
+                exchange -> {
+                    int tokenNumber = iamRequests.incrementAndGet();
+                    sendJson(
+                            exchange,
+                            200,
+                            "{\"access_token\":\"token-" + tokenNumber + 
"\",\"expires_in\":60}");
+                });
+        server.createContext(
+                "/ml/v1/text/chat", exchange -> sendJson(exchange, 200, 
CHAT_RESPONSE));
+
+        try {
+            WatsonxChatModelConnection connection =
+                    new WatsonxChatModelConnection(
+                            stubDescriptor(baseUrl(server), true, 0), NOOP, 
NO_ENVIRONMENT);
+            chat(connection);
+            chat(connection);
+            assertThat(iamRequests).hasValue(2);
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {401, 403})
+    @DisplayName("Rejected IAM token is refreshed and retried once")
+    void testRejectedIamTokenIsRefreshed(int rejectedStatus) throws Exception {
+        HttpServer server = startServer();
+        AtomicInteger iamRequests = new AtomicInteger();
+        AtomicInteger chatRequests = new AtomicInteger();
+        server.createContext(
+                "/identity/token",
+                exchange -> {
+                    int tokenNumber = iamRequests.incrementAndGet();
+                    sendJson(
+                            exchange,
+                            200,
+                            "{\"access_token\":\"token-" + tokenNumber + 
"\",\"expires_in\":3600}");
+                });
+        server.createContext(
+                "/ml/v1/text/chat",
+                exchange -> {
+                    int requestNumber = chatRequests.incrementAndGet();
+                    sendJson(
+                            exchange,
+                            requestNumber == 1 ? rejectedStatus : 200,
+                            requestNumber == 1 ? "{}" : CHAT_RESPONSE);
+                });
+
+        try {
+            WatsonxChatModelConnection connection =
+                    new WatsonxChatModelConnection(
+                            stubDescriptor(baseUrl(server), true, 0), NOOP, 
NO_ENVIRONMENT);
+            assertThat(chat(connection).getContent()).isEqualTo("Hello!");
+            assertThat(iamRequests).hasValue(2);
+            assertThat(chatRequests).hasValue(2);
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    @DisplayName("Retryable response is retried while a client error is not")
+    void testRetryLoop() throws Exception {
+        HttpServer server = startServer();
+        AtomicInteger chatRequests = new AtomicInteger();
+        server.createContext(
+                "/ml/v1/text/chat",
+                exchange -> {
+                    int requestNumber = chatRequests.incrementAndGet();
+                    sendJson(
+                            exchange,
+                            requestNumber == 1 ? 503 : 200,
+                            requestNumber == 1 ? "{}" : CHAT_RESPONSE);
+                });
+
+        try {
+            WatsonxChatModelConnection retryingConnection =
+                    new WatsonxChatModelConnection(
+                            stubDescriptor(baseUrl(server), false, 1), NOOP, 
NO_ENVIRONMENT);
+            
assertThat(chat(retryingConnection).getContent()).isEqualTo("Hello!");
+            assertThat(chatRequests).hasValue(2);
+
+            server.removeContext("/ml/v1/text/chat");
+            chatRequests.set(0);
+            server.createContext(
+                    "/ml/v1/text/chat",
+                    exchange -> {
+                        chatRequests.incrementAndGet();
+                        sendJson(exchange, 400, "{\"error\":\"bad request\"}");
+                    });
+            WatsonxChatModelConnection nonRetryingConnection =
+                    new WatsonxChatModelConnection(
+                            stubDescriptor(baseUrl(server), false, 3), NOOP, 
NO_ENVIRONMENT);
+            assertThatThrownBy(() -> chat(nonRetryingConnection))
+                    .isInstanceOf(RuntimeException.class)
+                    .hasMessageContaining("status 400");
+            assertThat(chatRequests).hasValue(1);
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    @DisplayName("Reasoning blocks are extracted without changing plain 
content")
+    void testExtractReasoning() {
+        
assertThat(WatsonxChatModelConnection.extractReasoning("<think>Plan</think>\nAnswer"))
+                .containsExactly("Answer", "Plan");
+        String plainContent = "| 1  | 2  |\n\n    indented";
+        assertThat(WatsonxChatModelConnection.extractReasoning(plainContent))
+                .containsExactly(plainContent, null);
+    }
+
+    @Test
+    @DisplayName("Transient HTTP statuses are retryable and backoff honors 
Retry-After")
+    void testRetryPolicy() {
+        assertThat(WatsonxChatModelConnection.isRetryableStatus(408)).isTrue();
+        assertThat(WatsonxChatModelConnection.isRetryableStatus(429)).isTrue();
+        assertThat(WatsonxChatModelConnection.isRetryableStatus(500)).isTrue();
+        assertThat(WatsonxChatModelConnection.isRetryableStatus(502)).isTrue();
+        assertThat(WatsonxChatModelConnection.isRetryableStatus(503)).isTrue();
+        assertThat(WatsonxChatModelConnection.isRetryableStatus(504)).isTrue();
+        
assertThat(WatsonxChatModelConnection.isRetryableStatus(200)).isFalse();
+        
assertThat(WatsonxChatModelConnection.isRetryableStatus(400)).isFalse();
+        
assertThat(WatsonxChatModelConnection.isRetryableStatus(401)).isFalse();
+        
assertThat(WatsonxChatModelConnection.isRetryableStatus(501)).isFalse();
+        
assertThat(WatsonxChatModelConnection.isRetryableStatus(505)).isFalse();
+
+        // exponential backoff, capped
+        assertThat(WatsonxChatModelConnection.retryDelayMillis(0, 
null)).isEqualTo(1000L);
+        assertThat(WatsonxChatModelConnection.retryDelayMillis(1, 
null)).isEqualTo(2000L);
+        assertThat(WatsonxChatModelConnection.retryDelayMillis(10, 
null)).isEqualTo(10_000L);
+        // Retry-After wins when larger, is capped, and non-numeric values are 
ignored
+        assertThat(WatsonxChatModelConnection.retryDelayMillis(0, 
"5")).isEqualTo(5000L);
+        assertThat(WatsonxChatModelConnection.retryDelayMillis(0, 
"600")).isEqualTo(30_000L);
+        assertThat(WatsonxChatModelConnection.retryDelayMillis(0, 
"not-a-number")).isEqualTo(1000L);
+    }
+
+    @Test
+    @DisplayName("Tool arguments in messy model-emitted formats are parsed 
into a map")
+    void testParseToolArguments() throws Exception {
+        // clean JSON object string
+        assertThat(
+                        WatsonxChatModelConnection.parseToolArguments(
+                                MAPPER.readTree("\"{\\\"a\\\": 1, \\\"b\\\": 
2}\"")))
+                .isEqualTo(Map.of("a", 1, "b", 2));
+        // double-encoded JSON string
+        assertThat(
+                        WatsonxChatModelConnection.parseToolArguments(
+                                MAPPER.readTree("\"\\\"{\\\\\\\"a\\\\\\\": 
1}\\\"\"")))
+                .isEqualTo(Map.of("a", 1));
+        // single-quoted pseudo-JSON
+        assertThat(
+                        WatsonxChatModelConnection.parseToolArguments(
+                                MAPPER.readTree("\"{'a': 17, 'b': 25}\"")))
+                .isEqualTo(Map.of("a", 17, "b", 25));
+        // already an object node
+        
assertThat(WatsonxChatModelConnection.parseToolArguments(MAPPER.readTree("{\"a\":
 1}")))
+                .isEqualTo(Map.of("a", 1));
+        // missing or empty -> empty map
+        
assertThat(WatsonxChatModelConnection.parseToolArguments(null)).isEmpty();
+        
assertThat(WatsonxChatModelConnection.parseToolArguments(MAPPER.readTree("\"\"")))
+                .isEmpty();
+        // garbage -> descriptive error carrying the raw value
+        assertThatThrownBy(
+                        () ->
+                                WatsonxChatModelConnection.parseToolArguments(
+                                        MAPPER.readTree("\"not json at 
all\"")))
+                .isInstanceOf(RuntimeException.class)
+                .hasMessageContaining("not json at all");
+    }
+
+    @Test
+    @DisplayName("Chat response with tool calls parses arguments and preserves 
the original id")
+    void testParseResponseWithToolCalls() throws Exception {
+        JsonNode response =
+                MAPPER.readTree(
+                        "{\"choices\": [{\"index\": 0, \"message\": {\"role\": 
\"assistant\","
+                                + " \"tool_calls\": [{\"id\": \"call_abc123\", 
\"type\":"
+                                + " \"function\", \"function\": {\"name\": 
\"add\", \"arguments\":"
+                                + " \"{\\\"a\\\": 1, \\\"b\\\": 2}\"}}]}, 
\"finish_reason\":"
+                                + " \"tool_calls\"}]}");
+
+        ChatMessage message = 
WatsonxChatModelConnection.parseResponse(response, null);
+
+        assertThat(message.getToolCalls()).hasSize(1);
+        Map<String, Object> toolCall = message.getToolCalls().get(0);
+        assertThat(toolCall.get("id")).isEqualTo("call_abc123");
+        assertThat(toolCall.get("original_id")).isEqualTo("call_abc123");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> function = (Map<String, Object>) 
toolCall.get("function");
+        assertThat(function.get("name")).isEqualTo("add");
+        assertThat(function.get("arguments")).isEqualTo(Map.of("a", 1, "b", 
2));
+    }
+}
diff --git 
a/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelLiveTest.java
 
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelLiveTest.java
new file mode 100644
index 00000000..50197e18
--- /dev/null
+++ 
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelLiveTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.integrations.chatmodels.watsonx;
+
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIf;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Live tests for {@link WatsonxChatModelConnection} against the real 
watsonx.ai service.
+ *
+ * <p>The test requires {@code WATSONX_URL}, either {@code WATSONX_API_KEY} or 
{@code
+ * WATSONX_TOKEN}, and either {@code WATSONX_PROJECT_ID} or {@code 
WATSONX_SPACE_ID}. Override the
+ * model with {@code WATSONX_CHAT_MODEL} if the default is not available in 
your region.
+ */
+@EnabledIf("credentialsAvailable")
+class WatsonxChatModelLiveTest {
+
+    private static final ResourceContext NOOP = 
ResourceContext.fromGetResource((a, b) -> null);
+
+    static boolean credentialsAvailable() {
+        return isSet("WATSONX_URL")
+                && (isSet("WATSONX_API_KEY") || isSet("WATSONX_TOKEN"))
+                && (isSet("WATSONX_PROJECT_ID") || isSet("WATSONX_SPACE_ID"));
+    }
+
+    private static boolean isSet(String name) {
+        String value = System.getenv(name);
+        return value != null && !value.isBlank();
+    }
+
+    private static String model() {
+        String model = System.getenv("WATSONX_CHAT_MODEL");
+        return model != null ? model : WatsonxChatModelSetup.DEFAULT_MODEL;
+    }
+
+    private static WatsonxChatModelConnection connection() {
+        return new WatsonxChatModelConnection(
+                
ResourceDescriptor.Builder.newBuilder(WatsonxChatModelConnection.class.getName())
+                        .build(),
+                NOOP);
+    }
+
+    @Test
+    @DisplayName("Basic chat returns a non-empty assistant message")
+    void testBasicChat() {
+        ChatMessage response =
+                connection()
+                        .chat(
+                                List.of(
+                                        new ChatMessage(
+                                                MessageRole.USER,
+                                                "Say hello in one short 
sentence.")),
+                                List.of(),
+                                Map.of("model", model(), "max_tokens", 100));
+
+        assertThat(response.getRole()).isEqualTo(MessageRole.ASSISTANT);
+        assertThat(response.getContent()).isNotBlank();
+        assertThat(response.getExtraArgs().get("promptTokens")).isNotNull();
+        
assertThat(response.getExtraArgs().get("completionTokens")).isNotNull();
+    }
+}
diff --git 
a/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelSetupTest.java
 
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelSetupTest.java
new file mode 100644
index 00000000..e9cafeb3
--- /dev/null
+++ 
b/integrations/chat-models/watsonx/src/test/java/org/apache/flink/agents/integrations/chatmodels/watsonx/WatsonxChatModelSetupTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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.watsonx;
+
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Unit tests for {@link WatsonxChatModelSetup}. */
+class WatsonxChatModelSetupTest {
+
+    private static final ResourceContext NOOP = 
ResourceContext.fromGetResource((a, b) -> null);
+
+    @Test
+    @DisplayName("Default model applies when the model argument is omitted")
+    void testDefaultModel() {
+        WatsonxChatModelSetup setup =
+                new WatsonxChatModelSetup(
+                        
ResourceDescriptor.Builder.newBuilder(WatsonxChatModelSetup.class.getName())
+                                .addInitialArgument("connection", "watsonx")
+                                .build(),
+                        NOOP);
+
+        Map<String, Object> params = setup.getParameters();
+        
assertThat(params.get("model")).isEqualTo(WatsonxChatModelSetup.DEFAULT_MODEL);
+        
assertThat(params.get("temperature")).isEqualTo(WatsonxChatModelSetup.DEFAULT_TEMPERATURE);
+        assertThat(params).doesNotContainKey("max_tokens");
+    }
+
+    @Test
+    @DisplayName("Configured model, temperature and max_tokens are exposed as 
parameters")
+    void testConfiguredParameters() {
+        WatsonxChatModelSetup setup =
+                new WatsonxChatModelSetup(
+                        
ResourceDescriptor.Builder.newBuilder(WatsonxChatModelSetup.class.getName())
+                                .addInitialArgument("connection", "watsonx")
+                                .addInitialArgument("model", 
"ibm/granite-3-3-8b-instruct")
+                                .addInitialArgument("temperature", 0.2)
+                                .addInitialArgument("max_tokens", 512)
+                                .addInitialArgument("extract_reasoning", true)
+                                .addInitialArgument(
+                                        "additional_kwargs", Map.of("top_p", 
0.9, "seed", 7))
+                                .build(),
+                        NOOP);
+
+        Map<String, Object> params = setup.getParameters();
+        
assertThat(params.get("model")).isEqualTo("ibm/granite-3-3-8b-instruct");
+        assertThat(params.get("temperature")).isEqualTo(0.2);
+        assertThat(params.get("max_tokens")).isEqualTo(512);
+        assertThat(params.get("extract_reasoning")).isEqualTo(true);
+        assertThat(params.get("additional_kwargs")).isEqualTo(Map.of("top_p", 
0.9, "seed", 7));
+    }
+
+    @Test
+    @DisplayName("Out-of-range and non-finite temperatures are rejected")
+    void testTemperatureValidation() {
+        for (double invalidTemperature :
+                new double[] {-0.1, 2.5, Double.NaN, 
Double.POSITIVE_INFINITY}) {
+            assertThatThrownBy(
+                            () ->
+                                    new WatsonxChatModelSetup(
+                                            
ResourceDescriptor.Builder.newBuilder(
+                                                            
WatsonxChatModelSetup.class.getName())
+                                                    
.addInitialArgument("connection", "watsonx")
+                                                    .addInitialArgument(
+                                                            "temperature", 
invalidTemperature)
+                                                    .build(),
+                                            NOOP))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("temperature");
+        }
+    }
+
+    @Test
+    @DisplayName("max_tokens must be a positive integer")
+    void testMaxTokensValidation() {
+        for (Number invalidMaxTokens :
+                new Number[] {0, -1, 1.5, Double.NaN, 
Double.POSITIVE_INFINITY}) {
+            assertThatThrownBy(
+                            () ->
+                                    new WatsonxChatModelSetup(
+                                            
ResourceDescriptor.Builder.newBuilder(
+                                                            
WatsonxChatModelSetup.class.getName())
+                                                    
.addInitialArgument("connection", "watsonx")
+                                                    .addInitialArgument(
+                                                            "max_tokens", 
invalidMaxTokens)
+                                                    .build(),
+                                            NOOP))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("max_tokens");
+        }
+    }
+}
diff --git a/python/flink_agents/api/chat_models/chat_model.py 
b/python/flink_agents/api/chat_models/chat_model.py
index c3a9d786..cb1c663b 100644
--- a/python/flink_agents/api/chat_models/chat_model.py
+++ b/python/flink_agents/api/chat_models/chat_model.py
@@ -217,7 +217,10 @@ class BaseChatModelConnection(Resource, ABC):
                 reasoning_chunks.extend(m.strip() for m in matches if 
m.strip())
                 cleaned = pat.sub("", cleaned)
 
-        reasoning = "\n\n".join(reasoning_chunks) if reasoning_chunks else None
+        if not reasoning_chunks:
+            return cleaned, None
+
+        reasoning = "\n\n".join(reasoning_chunks)
         cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
         cleaned = re.sub(r" {2,}", " ", cleaned)
         cleaned = cleaned.strip()
diff --git a/python/flink_agents/api/resource.py 
b/python/flink_agents/api/resource.py
index 11542c5d..4dcc904f 100644
--- a/python/flink_agents/api/resource.py
+++ b/python/flink_agents/api/resource.py
@@ -266,6 +266,10 @@ class ResourceName:
         VLLM_CONNECTION = 
"flink_agents.integrations.chat_models.vllm.vllm_chat_model.VLLMChatModelConnection"
         VLLM_SETUP = 
"flink_agents.integrations.chat_models.vllm.vllm_chat_model.VLLMChatModelSetup"
 
+        # Watsonx
+        WATSONX_CONNECTION = 
"flink_agents.integrations.chat_models.watsonx.watsonx_chat_model.WatsonxChatModelConnection"
+        WATSONX_SETUP = 
"flink_agents.integrations.chat_models.watsonx.watsonx_chat_model.WatsonxChatModelSetup"
+
         # Java Wrapper
         JAVA_WRAPPER_CONNECTION = (
             
"flink_agents.api.chat_models.java_chat_model.JavaChatModelConnection"
@@ -308,6 +312,10 @@ class ResourceName:
             VLLM_CONNECTION = 
"org.apache.flink.agents.integrations.chatmodels.openai.VLLMChatModelConnection"
             VLLM_SETUP = 
"org.apache.flink.agents.integrations.chatmodels.openai.VLLMChatModelSetup"
 
+            # IBM watsonx.ai
+            WATSONX_CONNECTION = 
"org.apache.flink.agents.integrations.chatmodels.watsonx.WatsonxChatModelConnection"
+            WATSONX_SETUP = 
"org.apache.flink.agents.integrations.chatmodels.watsonx.WatsonxChatModelSetup"
+
     class EmbeddingModel:
         """EmbeddingModel resource names."""
 
diff --git a/python/flink_agents/api/yaml/aliases.py 
b/python/flink_agents/api/yaml/aliases.py
index 65dd5fe1..584ba2fd 100644
--- a/python/flink_agents/api/yaml/aliases.py
+++ b/python/flink_agents/api/yaml/aliases.py
@@ -67,6 +67,7 @@ CLAZZ_ALIASES: Dict[ResourceType, Dict[str, Dict[str, str]]] 
= {
             "tongyi": ResourceName.ChatModel.TONGYI_CONNECTION,
             "azure_openai": ResourceName.ChatModel.AZURE_OPENAI_CONNECTION,
             "vllm": ResourceName.ChatModel.VLLM_CONNECTION,
+            "watsonx": ResourceName.ChatModel.WATSONX_CONNECTION,
         },
         "java": {
             "ollama": ResourceName.ChatModel.Java.OLLAMA_CONNECTION,
@@ -77,6 +78,7 @@ CLAZZ_ALIASES: Dict[ResourceType, Dict[str, Dict[str, str]]] 
= {
             "azure_openai": 
ResourceName.ChatModel.Java.AZURE_OPENAI_CONNECTION,
             "bedrock": ResourceName.ChatModel.Java.BEDROCK_CONNECTION,
             "vllm": ResourceName.ChatModel.Java.VLLM_CONNECTION,
+            "watsonx": ResourceName.ChatModel.Java.WATSONX_CONNECTION,
         },
     },
     ResourceType.CHAT_MODEL: {
@@ -87,6 +89,7 @@ CLAZZ_ALIASES: Dict[ResourceType, Dict[str, Dict[str, str]]] 
= {
             "tongyi": ResourceName.ChatModel.TONGYI_SETUP,
             "azure_openai": ResourceName.ChatModel.AZURE_OPENAI_SETUP,
             "vllm": ResourceName.ChatModel.VLLM_SETUP,
+            "watsonx": ResourceName.ChatModel.WATSONX_SETUP,
         },
         "java": {
             "ollama": ResourceName.ChatModel.Java.OLLAMA_SETUP,
@@ -97,6 +100,7 @@ CLAZZ_ALIASES: Dict[ResourceType, Dict[str, Dict[str, str]]] 
= {
             "azure_openai": ResourceName.ChatModel.Java.AZURE_OPENAI_SETUP,
             "bedrock": ResourceName.ChatModel.Java.BEDROCK_SETUP,
             "vllm": ResourceName.ChatModel.Java.VLLM_SETUP,
+            "watsonx": ResourceName.ChatModel.Java.WATSONX_SETUP,
         },
     },
     ResourceType.EMBEDDING_MODEL_CONNECTION: {
diff --git a/python/flink_agents/api/yaml/tests/test_aliases.py 
b/python/flink_agents/api/yaml/tests/test_aliases.py
index dfb3b296..d7ef0bc0 100644
--- a/python/flink_agents/api/yaml/tests/test_aliases.py
+++ b/python/flink_agents/api/yaml/tests/test_aliases.py
@@ -28,7 +28,7 @@ from flink_agents.api.events.tool_event import (
     ToolRequestEvent,
     ToolResponseEvent,
 )
-from flink_agents.api.resource import ResourceType
+from flink_agents.api.resource import ResourceName, ResourceType
 from flink_agents.api.yaml.aliases import (
     CLAZZ_ALIASES,
     EVENT_ALIASES,
@@ -121,6 +121,25 @@ def test_resolve_clazz_dispatches_on_language() -> None:
     assert py.startswith("flink_agents")
 
 
+def test_watsonx_aliases_resolve_for_java_and_python() -> None:
+    assert (
+        resolve_clazz("watsonx", ResourceType.CHAT_MODEL_CONNECTION, "python")
+        == ResourceName.ChatModel.WATSONX_CONNECTION
+    )
+    assert (
+        resolve_clazz("watsonx", ResourceType.CHAT_MODEL, "python")
+        == ResourceName.ChatModel.WATSONX_SETUP
+    )
+    assert (
+        resolve_clazz("watsonx", ResourceType.CHAT_MODEL_CONNECTION, "java")
+        == ResourceName.ChatModel.Java.WATSONX_CONNECTION
+    )
+    assert (
+        resolve_clazz("watsonx", ResourceType.CHAT_MODEL, "java")
+        == ResourceName.ChatModel.Java.WATSONX_SETUP
+    )
+
+
 def test_resolve_clazz_default_language_is_python() -> None:
     default = resolve_clazz("ollama", ResourceType.CHAT_MODEL_CONNECTION)
     explicit = resolve_clazz("ollama", ResourceType.CHAT_MODEL_CONNECTION, 
"python")
diff --git 
a/python/flink_agents/integrations/chat_models/tests/test_ollama_chat_model.py 
b/python/flink_agents/integrations/chat_models/tests/test_ollama_chat_model.py
index 1f3fd261..e9782649 100644
--- 
a/python/flink_agents/integrations/chat_models/tests/test_ollama_chat_model.py
+++ 
b/python/flink_agents/integrations/chat_models/tests/test_ollama_chat_model.py
@@ -136,7 +136,7 @@ def test_extract_think_tags() -> None:
     assert reasoning == "This is just my thought process."
 
     # Test with no think tags
-    content = "This is a regular response without any thinking tags."
+    content = "| 1  | 2  |\n\n    This is a regular response without thinking 
tags."
     cleaned, reasoning = OllamaChatModelConnection._extract_reasoning(content)
     assert cleaned == content
     assert reasoning is None
diff --git 
a/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
 
b/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
index 9b814c3c..61da5d6f 100644
--- 
a/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
+++ 
b/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
@@ -33,6 +33,7 @@ from flink_agents.integrations.chat_models import 
ollama_chat_model, tongyi_chat
 from flink_agents.integrations.chat_models.anthropic import 
anthropic_chat_model
 from flink_agents.integrations.chat_models.azure import azure_openai_chat_model
 from flink_agents.integrations.chat_models.openai import openai_chat_model
+from flink_agents.integrations.chat_models.watsonx import watsonx_chat_model
 from flink_agents.runtime.java import java_chat_model as 
runtime_java_chat_model
 
 # A class is only discoverable through __subclasses__() once it has been 
imported.
@@ -48,6 +49,7 @@ _MODULES_DEFINING_CONNECTIONS = (
     runtime_java_chat_model,
     tongyi_chat_model,
     tool_parameter_injection_agent,
+    watsonx_chat_model,
 )
 
 
diff --git a/python/flink_agents/integrations/chat_models/watsonx/__init__.py 
b/python/flink_agents/integrations/chat_models/watsonx/__init__.py
new file mode 100644
index 00000000..e154fadd
--- /dev/null
+++ b/python/flink_agents/integrations/chat_models/watsonx/__init__.py
@@ -0,0 +1,17 @@
+################################################################################
+#  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.
+#################################################################################
diff --git 
a/python/flink_agents/integrations/chat_models/watsonx/tests/__init__.py 
b/python/flink_agents/integrations/chat_models/watsonx/tests/__init__.py
new file mode 100644
index 00000000..e154fadd
--- /dev/null
+++ b/python/flink_agents/integrations/chat_models/watsonx/tests/__init__.py
@@ -0,0 +1,17 @@
+################################################################################
+#  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.
+#################################################################################
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
new file mode 100644
index 00000000..57063f60
--- /dev/null
+++ 
b/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_chat_model.py
@@ -0,0 +1,393 @@
+################################################################################
+#  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 os
+from typing import Any, Dict
+from unittest.mock import MagicMock
+
+import pytest
+
+from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.api.resource import Resource, ResourceType
+from flink_agents.api.resource_context import ResourceContext
+from flink_agents.integrations.chat_models.watsonx.watsonx_chat_model import (
+    DEFAULT_MODEL,
+    WatsonxChatModelConnection,
+    WatsonxChatModelSetup,
+    convert_to_watsonx_messages,
+)
+
+test_model = os.environ.get("WATSONX_CHAT_MODEL", DEFAULT_MODEL)
+credentials_available = (
+    "WATSONX_URL" in os.environ
+    and ("WATSONX_API_KEY" in os.environ or "WATSONX_TOKEN" in os.environ)
+) and ("WATSONX_PROJECT_ID" in os.environ or "WATSONX_SPACE_ID" in os.environ)
+
+
+def _fake_connection(**kwargs: Any) -> WatsonxChatModelConnection:
+    """Create a connection with fake credentials for offline tests."""
+    return WatsonxChatModelConnection(
+        name="watsonx",
+        url=kwargs.pop("url", "https://us-south.ml.cloud.ibm.com";),
+        api_key=kwargs.pop("api_key", "fake-key"),
+        project_id=kwargs.pop("project_id", "fake-project"),
+        **kwargs,
+    )
+
+
[email protected]
[email protected](
+    not credentials_available, reason="watsonx.ai credentials are not set"
+)
+def test_watsonx_chat() -> None:
+    """Test basic chat functionality of WatsonxChatModelConnection."""
+    connection = WatsonxChatModelConnection(name="watsonx")
+    response = connection.chat(
+        [ChatMessage(role=MessageRole.USER, content="Hello!")], 
model=test_model
+    )
+    assert response is not None
+    assert response.content is not None
+    assert response.content.strip() != ""
+    assert response.role == MessageRole.ASSISTANT
+
+
+def _mock_chat_response(
+    message: Dict[str, Any], finish_reason: str = "stop"
+) -> Dict[str, Any]:
+    return {
+        "id": "chatcmpl-1",
+        "model_id": test_model,
+        "choices": [{"index": 0, "message": message, "finish_reason": 
finish_reason}],
+        "usage": {
+            "prompt_tokens": 100,
+            "completion_tokens": 50,
+            "total_tokens": 150,
+        },
+    }
+
+
+def test_watsonx_chat_mocked(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Test chat response handling and params passing (mock watsonx client)."""
+    mock_model = MagicMock()
+    mock_model.chat.return_value = _mock_chat_response(
+        {"role": "assistant", "content": "Hello there!"}
+    )
+    model_inference = MagicMock(return_value=mock_model)
+    monkeypatch.setattr(
+        
"flink_agents.integrations.chat_models.watsonx.watsonx_chat_model.ModelInference",
+        model_inference,
+    )
+
+    connection = _fake_connection()
+    api_client = MagicMock()
+    connection._client = api_client
+
+    def get_resource(name: str, type: ResourceType) -> Resource:
+        return connection
+
+    mock_ctx = MagicMock(spec=ResourceContext)
+    mock_ctx.get_resource = get_resource
+
+    llm = WatsonxChatModelSetup(
+        name="watsonx",
+        model=test_model,
+        connection="watsonx",
+        temperature=0.5,
+        max_tokens=256,
+        additional_kwargs={"top_p": 0.9},
+        resource_context=mock_ctx,
+    )
+
+    llm.open()
+
+    response = llm.chat(
+        [ChatMessage(role=MessageRole.USER, content="Hello!")], top_p=0.5
+    )
+
+    mock_model.chat.assert_called_once()
+    call_kwargs = mock_model.chat.call_args.kwargs
+    assert call_kwargs["messages"] == [{"role": "user", "content": "Hello!"}]
+    assert call_kwargs["params"] == {
+        "temperature": 0.5,
+        "max_tokens": 256,
+        "top_p": 0.5,
+    }
+
+    assert response.role == MessageRole.ASSISTANT
+    assert response.content == "Hello there!"
+    assert response.extra_args["model_name"] == test_model
+    assert response.extra_args["promptTokens"] == 100
+    assert response.extra_args["completionTokens"] == 50
+    model_inference.assert_called_once_with(
+        model_id=test_model,
+        api_client=api_client,
+        project_id="fake-project",
+        space_id=None,
+        max_retries=0,
+    )
+
+
+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()
+    mock_model.chat.return_value = _mock_chat_response(
+        {
+            "role": "assistant",
+            "tool_calls": [
+                {
+                    "id": "call_abc123",
+                    "type": "function",
+                    "function": {"name": "add", "arguments": '{"a": 1, "b": 
2}'},
+                }
+            ],
+        }
+    )
+    monkeypatch.setattr(
+        WatsonxChatModelConnection, "_get_model", lambda self, model: 
mock_model
+    )
+
+    connection = _fake_connection()
+    response = connection.chat(
+        [ChatMessage(role=MessageRole.USER, content="What is 1 + 2?")],
+        model=test_model,
+    )
+
+    assert len(response.tool_calls) == 1
+    tool_call = response.tool_calls[0]
+    assert tool_call["function"]["name"] == "add"
+    assert tool_call["function"]["arguments"] == {"a": 1, "b": 2}
+    assert tool_call["original_id"] == "call_abc123"
+
+
+def test_chat_retries_transient_failures(monkeypatch: pytest.MonkeyPatch) -> 
None:
+    """Transient HTTP failures are retried up to max_retries, then succeed."""
+    import httpx
+    from ibm_watsonx_ai.wml_client_error import ApiRequestFailure
+
+    request = httpx.Request("POST", "https://test.invalid/ml/v1/text/chat";)
+    rate_limited = ApiRequestFailure(
+        "rate limited",
+        httpx.Response(
+            429,
+            text="too many requests",
+            headers={"Retry-After": "5"},
+            request=request,
+        ),
+    )
+    mock_model = MagicMock()
+    mock_model.chat.side_effect = [
+        rate_limited,
+        rate_limited,
+        _mock_chat_response({"role": "assistant", "content": "Recovered!"}),
+    ]
+    monkeypatch.setattr(
+        WatsonxChatModelConnection, "_get_model", lambda self, model: 
mock_model
+    )
+    sleep = MagicMock()
+    monkeypatch.setattr(
+        
"flink_agents.integrations.chat_models.watsonx.watsonx_chat_model.time.sleep",
+        sleep,
+    )
+
+    connection = _fake_connection(max_retries=3)
+    response = connection.chat(
+        [ChatMessage(role=MessageRole.USER, content="Hello!")], 
model=test_model
+    )
+
+    assert response.content == "Recovered!"
+    assert mock_model.chat.call_count == 3
+    assert [call.args[0] for call in sleep.call_args_list] == [5, 5]
+
+    # Non-retryable failures propagate immediately.
+    unauthorized = ApiRequestFailure(
+        "bad key", httpx.Response(401, text="unauthorized", request=request)
+    )
+    mock_model.chat.side_effect = [unauthorized]
+    mock_model.chat.reset_mock()
+    with pytest.raises(ApiRequestFailure):
+        connection.chat(
+            [ChatMessage(role=MessageRole.USER, content="Hello!")], 
model=test_model
+        )
+    assert mock_model.chat.call_count == 1
+
+
+def test_connection_close() -> None:
+    """close() releases the HTTP client and cached models without errors."""
+    connection = _fake_connection()
+    connection.close()  # closing before any request is a no-op
+
+    connection._http_client = MagicMock()
+    http_client = connection._http_client
+    connection.close()
+    http_client.close.assert_called_once()
+    assert connection._http_client is None
+    assert connection._models == {}
+
+
+def test_parse_tool_arguments_messy_formats() -> None:
+    """Tool arguments in messy model-emitted formats are parsed into a dict."""
+    from flink_agents.integrations.chat_models.watsonx.watsonx_chat_model 
import (
+        _parse_tool_arguments,
+    )
+
+    assert _parse_tool_arguments('{"a": 1, "b": 2}') == {"a": 1, "b": 2}
+    # double-encoded JSON string
+    assert _parse_tool_arguments('"{\\"a\\": 1}"') == {"a": 1}
+    # single-quoted / Python-literal style dict
+    assert _parse_tool_arguments("{'a': 17, 'b': 25}") == {"a": 17, "b": 25}
+    # already a dict
+    assert _parse_tool_arguments({"a": 1}) == {"a": 1}
+    # missing or empty -> empty dict
+    assert _parse_tool_arguments(None) == {}
+    assert _parse_tool_arguments("") == {}
+    # garbage -> descriptive error carrying the raw value
+    with pytest.raises(TypeError, match="not json at all"):
+        _parse_tool_arguments("not json at all")
+
+
+def test_convert_to_watsonx_messages_round_trip() -> None:
+    """Test conversion of assistant tool calls and tool results to watsonx 
format."""
+    messages = [
+        ChatMessage(role=MessageRole.SYSTEM, content="You are helpful."),
+        ChatMessage(role=MessageRole.USER, content="What is 1 + 2?"),
+        ChatMessage(
+            role=MessageRole.ASSISTANT,
+            tool_calls=[
+                {
+                    "id": "internal-id",
+                    "type": "function",
+                    "function": {"name": "add", "arguments": {"a": 1, "b": 2}},
+                    "original_id": "call_abc123",
+                }
+            ],
+        ),
+        ChatMessage(
+            role=MessageRole.TOOL,
+            content="3",
+            extra_args={"external_id": "call_abc123"},
+        ),
+    ]
+
+    converted = convert_to_watsonx_messages(messages)
+
+    assert converted[0] == {"role": "system", "content": "You are helpful."}
+    assert converted[1] == {"role": "user", "content": "What is 1 + 2?"}
+    assert converted[2] == {
+        "role": "assistant",
+        "tool_calls": [
+            {
+                "id": "call_abc123",
+                "type": "function",
+                "function": {"name": "add", "arguments": '{"a": 1, "b": 2}'},
+            }
+        ],
+    }
+    assert converted[3] == {
+        "role": "tool",
+        "content": "3",
+        "tool_call_id": "call_abc123",
+    }
+
+
+def test_configuration_contract(monkeypatch: pytest.MonkeyPatch) -> None:
+    """Validate required connection fields, scoping, timeout, and defaults."""
+    for var in (
+        "WATSONX_URL",
+        "WATSONX_API_KEY",
+        "WATSONX_TOKEN",
+        "WATSONX_PROJECT_ID",
+        "WATSONX_SPACE_ID",
+    ):
+        monkeypatch.delenv(var, raising=False)
+
+    with pytest.raises(ValueError, match="url"):
+        WatsonxChatModelConnection(name="watsonx")
+
+    with pytest.raises(ValueError, match="credentials"):
+        WatsonxChatModelConnection(
+            name="watsonx", url="https://us-south.ml.cloud.ibm.com";
+        )
+
+    with pytest.raises(ValueError, match="project or space"):
+        WatsonxChatModelConnection(
+            name="watsonx",
+            url="https://us-south.ml.cloud.ibm.com";,
+            api_key="fake-key",
+        )
+
+    connection = WatsonxChatModelConnection(
+        name="watsonx",
+        url=" https://us-south.ml.cloud.ibm.com ",
+        api_key=" fake-key ",
+        space_id=" fake-space ",
+    )
+
+    assert connection.url == "https://us-south.ml.cloud.ibm.com";
+    assert connection.api_key == "fake-key"
+    assert connection.project_id is None
+    assert connection.space_id == "fake-space"
+
+    with pytest.raises(ValueError, match=r"cannot both be provided.*exactly 
one"):
+        WatsonxChatModelConnection(
+            name="watsonx",
+            url="https://us-south.ml.cloud.ibm.com";,
+            api_key="fake-key",
+            project_id="fake-project",
+            space_id="fake-space",
+        )
+
+    with pytest.raises(ValueError, match=r"api_key and token.*exactly one"):
+        WatsonxChatModelConnection(
+            name="watsonx",
+            url=" https://us-south.ml.cloud.ibm.com ",
+            api_key=" fake-key ",
+            token=" fake-token ",
+            project_id=" fake-project ",
+        )
+
+    for request_timeout in (0, -1, float("nan"), float("inf")):
+        with pytest.raises(ValueError, match="request_timeout"):
+            _fake_connection(request_timeout=request_timeout)
+
+    assert WatsonxChatModelSetup(connection="conn").model == DEFAULT_MODEL
+
+    with pytest.raises(ValueError, match="additional_kwargs"):
+        _fake_connection().chat(
+            [ChatMessage(role=MessageRole.USER, content="Hello!")],
+            model=test_model,
+            additional_kwargs={"temperature": 5.0},
+        )
+
+
[email protected](
+    "reserved_key", ["model_id", "messages", "tools", "project_id", "space_id"]
+)
+def test_additional_kwargs_reject_request_owned_fields(reserved_key: str) -> 
None:
+    """Framework-owned request fields cannot be replaced by static 
configuration."""
+    with pytest.raises(ValueError, match=reserved_key):
+        _fake_connection().chat(
+            [ChatMessage(role=MessageRole.USER, content="Hello!")],
+            model=test_model,
+            additional_kwargs={reserved_key: "override"},
+        )
+    if reserved_key not in {"messages", "tools"}:
+        with pytest.raises(ValueError, match=reserved_key):
+            _fake_connection().chat(
+                [ChatMessage(role=MessageRole.USER, content="Hello!")],
+                model=test_model,
+                **{reserved_key: "override"},
+            )
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
new file mode 100644
index 00000000..6544d221
--- /dev/null
+++ b/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
@@ -0,0 +1,498 @@
+################################################################################
+#  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 ast
+import contextlib
+import json
+import logging
+import os
+import time
+import uuid
+from typing import Any, Dict, List, Sequence
+
+import httpx
+from ibm_watsonx_ai import APIClient, Credentials
+from ibm_watsonx_ai.foundation_models import ModelInference
+from ibm_watsonx_ai.wml_client_error import ApiRequestFailure
+from pydantic import Field, PrivateAttr
+from typing_extensions import override
+
+from flink_agents.api.agents.types import OutputSchema
+from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.api.chat_models.chat_model import (
+    BaseChatModelConnection,
+    BaseChatModelSetup,
+)
+from flink_agents.api.tools.tool import Tool
+from flink_agents.integrations.chat_models.chat_model_utils import 
to_openai_tool
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_MODEL = "ibm/granite-4-h-small"
+DEFAULT_REQUEST_TIMEOUT = 120.0
+DEFAULT_MAX_RETRIES = 3
+RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
+REQUEST_OWNED_PARAMS = frozenset(
+    {"model_id", "messages", "tools", "project_id", "space_id"}
+)
+RESERVED_ADDITIONAL_KWARGS = frozenset(
+    {
+        "model",
+        "temperature",
+        "max_tokens",
+        "extract_reasoning",
+        "tool_choice",
+        "tool_choice_option",
+    }
+) | REQUEST_OWNED_PARAMS
+
+
+def _normalize(value: str | None) -> str | None:
+    if value is None or not value.strip():
+        return None
+    return value.strip()
+
+
+def _retry_delay_seconds(attempt: int, response: httpx.Response | None) -> 
float:
+    """Return capped exponential backoff, honoring a numeric Retry-After 
header."""
+    backoff = min(2**attempt, 10)
+    if response is not None:
+        retry_after = response.headers.get("Retry-After")
+        if retry_after is not None:
+            with contextlib.suppress(ValueError):
+                return max(backoff, min(float(retry_after.strip()), 30))
+    return backoff
+
+
+def convert_to_watsonx_messages(
+    messages: Sequence[ChatMessage],
+) -> List[Dict[str, Any]]:
+    """Convert framework messages to the watsonx.ai chat format."""
+    watsonx_messages: List[Dict[str, Any]] = []
+    for message in messages:
+        role = message.role
+
+        if role == MessageRole.ASSISTANT:
+            assistant_message: Dict[str, Any] = {"role": "assistant"}
+            if message.content:
+                assistant_message["content"] = message.content
+            if message.tool_calls:
+                assistant_message["tool_calls"] = [
+                    _convert_to_watsonx_tool_call(tool_call)
+                    for tool_call in message.tool_calls
+                ]
+            watsonx_messages.append(assistant_message)
+        elif role == MessageRole.TOOL:
+            tool_call_id = message.extra_args.get("external_id")
+            if not tool_call_id or not isinstance(tool_call_id, str):
+                msg = "Tool message must have 'external_id' as a string in 
extra_args"
+                raise ValueError(msg)
+            watsonx_messages.append(
+                {
+                    "role": "tool",
+                    "content": message.content,
+                    "tool_call_id": tool_call_id,
+                }
+            )
+        else:
+            watsonx_messages.append({"role": role.value, "content": 
message.content})
+    return watsonx_messages
+
+
+def _parse_tool_arguments(args: Any) -> Dict[str, Any]:
+    """Parse model-emitted tool arguments, including common malformed 
variants."""
+    if args is None or args == "":
+        return {}
+    raw = args
+    for _ in range(3):
+        if not isinstance(args, str):
+            break
+        try:
+            args = json.loads(args)
+        except ValueError:
+            with contextlib.suppress(Exception):
+                literal = ast.literal_eval(args)
+                if isinstance(literal, dict):
+                    args = literal
+            break
+    if not isinstance(args, dict):
+        msg = (
+            "Failed to parse tool call arguments returned by watsonx.ai "
+            f"as a JSON object: {raw!r}"
+        )
+        raise TypeError(msg)
+    return args
+
+
+def _convert_to_watsonx_tool_call(tool_call: Dict[str, Any]) -> Dict[str, Any]:
+    """Convert a framework tool call to watsonx.ai format."""
+    watsonx_tool_call_id = tool_call.get("original_id")
+    if watsonx_tool_call_id is None:
+        tool_call_id = tool_call.get("id")
+        if tool_call_id is None:
+            msg = "Tool call must have either 'original_id' or 'id' field"
+            raise ValueError(msg)
+        watsonx_tool_call_id = str(tool_call_id)
+
+    arguments = tool_call["function"]["arguments"]
+    return {
+        "id": watsonx_tool_call_id,
+        "type": "function",
+        "function": {
+            "name": tool_call["function"]["name"],
+            "arguments": json.dumps(arguments)
+            if isinstance(arguments, dict)
+            else arguments,
+        },
+    }
+
+
+class WatsonxChatModelConnection(BaseChatModelConnection):
+    """Connection to the IBM watsonx.ai chat API."""
+
+    url: str = Field(description="The watsonx.ai service endpoint.")
+    api_key: str | None = Field(default=None, description="The IBM Cloud API 
key.")
+    token: str | None = Field(
+        default=None, description="A bearer token, as an alternative to 
api_key."
+    )
+    project_id: str | None = Field(
+        default=None, description="The watsonx.ai project id."
+    )
+    space_id: str | None = Field(
+        default=None, description="The watsonx.ai deployment space id."
+    )
+    request_timeout: float = Field(
+        default=DEFAULT_REQUEST_TIMEOUT,
+        description="The timeout, in seconds, for chat requests to 
watsonx.ai.",
+        gt=0,
+        allow_inf_nan=False,
+    )
+    max_retries: int = Field(
+        default=DEFAULT_MAX_RETRIES,
+        description="Maximum number of retries for transient failures.",
+        ge=0,
+    )
+
+    _client: APIClient | None = PrivateAttr(default=None)
+    _http_client: httpx.Client | None = PrivateAttr(default=None)
+    _models: Dict[str, ModelInference] = PrivateAttr(default_factory=dict)
+
+    def __init__(
+        self,
+        *,
+        url: str | None = None,
+        api_key: str | None = None,
+        token: str | None = None,
+        project_id: str | None = None,
+        space_id: str | None = None,
+        request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
+        max_retries: int = DEFAULT_MAX_RETRIES,
+        **kwargs: Any,
+    ) -> None:
+        """Initialize the connection."""
+        resolved_url = _normalize(url) or 
_normalize(os.environ.get("WATSONX_URL"))
+        resolved_api_key = _normalize(api_key) or _normalize(
+            os.environ.get("WATSONX_API_KEY")
+        )
+        resolved_token = _normalize(token) or _normalize(
+            os.environ.get("WATSONX_TOKEN")
+        )
+        resolved_project_id = _normalize(project_id) or _normalize(
+            os.environ.get("WATSONX_PROJECT_ID")
+        )
+        resolved_space_id = _normalize(space_id) or _normalize(
+            os.environ.get("WATSONX_SPACE_ID")
+        )
+
+        if not resolved_url:
+            msg = (
+                "watsonx.ai url is not provided. Please pass it as an argument 
"
+                "or set the 'WATSONX_URL' environment variable."
+            )
+            raise ValueError(msg)
+        if not resolved_api_key and not resolved_token:
+            msg = (
+                "watsonx.ai credentials are not provided. Please pass 
'api_key' "
+                "or 'token' as an argument, or set the 'WATSONX_API_KEY' or "
+                "'WATSONX_TOKEN' environment variable."
+            )
+            raise ValueError(msg)
+        if resolved_api_key and resolved_token:
+            msg = (
+                "watsonx.ai api_key and token cannot both be provided. Please 
configure "
+                "exactly one credential source."
+            )
+            raise ValueError(msg)
+        if not resolved_project_id and not resolved_space_id:
+            msg = (
+                "watsonx.ai project or space is not provided. Please pass "
+                "'project_id' or 'space_id' as an argument, or set the "
+                "'WATSONX_PROJECT_ID' or 'WATSONX_SPACE_ID' environment 
variable."
+            )
+            raise ValueError(msg)
+        if resolved_project_id and resolved_space_id:
+            msg = (
+                "watsonx.ai project and space cannot both be provided. Please 
configure "
+                "exactly one of 'project_id' or 'space_id'."
+            )
+            raise ValueError(msg)
+
+        super().__init__(
+            url=resolved_url,
+            api_key=resolved_api_key,
+            token=resolved_token,
+            project_id=resolved_project_id,
+            space_id=resolved_space_id,
+            request_timeout=request_timeout,
+            max_retries=max_retries,
+            **kwargs,
+        )
+
+    @property
+    def client(self) -> APIClient:
+        """Return the lazily initialized API client."""
+        if self._client is None:
+            credential_kwargs: Dict[str, Any] = {"url": self.url}
+            if self.api_key:
+                credential_kwargs["api_key"] = self.api_key
+            if self.token:
+                credential_kwargs["token"] = self.token
+            self._http_client = httpx.Client(timeout=self.request_timeout)
+            self._client = APIClient(
+                credentials=Credentials(**credential_kwargs),
+                project_id=self.project_id,
+                space_id=self.space_id,
+                httpx_client=self._http_client,
+            )
+        return self._client
+
+    @override
+    def close(self) -> None:
+        """Close the underlying HTTP client."""
+        self._models = {}
+        self._client = None
+        if self._http_client is not None:
+            with contextlib.suppress(Exception):
+                self._http_client.close()
+            self._http_client = None
+
+    def _get_model(self, model: str) -> ModelInference:
+        if model not in self._models:
+            self._models[model] = ModelInference(
+                model_id=model,
+                api_client=self.client,
+                project_id=self.project_id,
+                space_id=self.space_id,
+                max_retries=0,
+            )
+        return self._models[model]
+
+    def _chat_with_retry(self, model_name: str, **chat_kwargs: Any) -> 
Dict[str, Any]:
+        """Call chat with retries for selected HTTP statuses and transport 
failures.
+
+        Retries use capped exponential backoff and honor a numeric 
``Retry-After``
+        response header.
+        """
+        attempt = 0
+        while True:
+            try:
+                return self._get_model(model_name).chat(**chat_kwargs)
+            except (ApiRequestFailure, httpx.TransportError) as e:  # noqa: 
PERF203
+                response = getattr(e, "response", None)
+                status = getattr(response, "status_code", None)
+                retryable = isinstance(e, httpx.TransportError) or (
+                    status in RETRYABLE_STATUS_CODES
+                )
+                if attempt >= self.max_retries or not retryable:
+                    raise
+                delay = _retry_delay_seconds(attempt, response)
+                logger.warning(
+                    "watsonx.ai chat request for model %s failed with %s; "
+                    "retry %d/%d in %ds",
+                    model_name,
+                    status if status is not None else type(e).__name__,
+                    attempt + 1,
+                    self.max_retries,
+                    delay,
+                )
+                time.sleep(delay)
+                attempt += 1
+
+    def chat(
+        self,
+        messages: Sequence[ChatMessage],
+        tools: List[Tool] | None = None,
+        output_schema: OutputSchema | None = None,
+        **kwargs: Any,
+    ) -> ChatMessage:
+        """Process a sequence of messages, and return a response.
+
+        A non-``None`` ``output_schema`` is rejected: this connection has no 
native
+        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.
+        """
+        self._reject_unsupported_output_schema(output_schema)
+        model_name = kwargs.pop("model", DEFAULT_MODEL)
+        extract_reasoning = bool(kwargs.pop("extract_reasoning", False))
+        tool_choice = kwargs.pop("tool_choice", None)
+        tool_choice_option = kwargs.pop("tool_choice_option", None)
+        additional_kwargs = kwargs.pop("additional_kwargs", None) or {}
+        collisions = RESERVED_ADDITIONAL_KWARGS & additional_kwargs.keys()
+        if collisions:
+            msg = (
+                "additional_kwargs must not contain reserved typed fields: "
+                f"{sorted(collisions)}. Set these via the corresponding Setup 
field instead."
+            )
+            raise ValueError(msg)
+
+        request_params = {**additional_kwargs, **kwargs}
+        collisions = REQUEST_OWNED_PARAMS & request_params.keys()
+        if collisions:
+            msg = (
+                "request parameters must not contain framework-owned fields: "
+                f"{sorted(collisions)}."
+            )
+            raise ValueError(msg)
+
+        tool_specs: List[Dict[str, Any]] | None = (
+            [to_openai_tool(metadata=tool.metadata) for tool in tools]
+            if tools
+            else None
+        )
+
+        response = self._chat_with_retry(
+            model_name,
+            messages=convert_to_watsonx_messages(messages),
+            tools=tool_specs,
+            tool_choice=tool_choice,
+            tool_choice_option=tool_choice_option,
+            params=request_params or None,
+        )
+
+        extra_args: Dict[str, Any] = {}
+
+        usage = response.get("usage")
+        if model_name and usage:
+            extra_args["model_name"] = model_name
+            extra_args["promptTokens"] = usage.get("prompt_tokens", 0)
+            extra_args["completionTokens"] = usage.get("completion_tokens", 0)
+
+        choice: Dict[str, Any] = response["choices"][0]
+        finish_reason = choice.get("finish_reason")
+        if finish_reason not in (None, "stop", "tool_calls"):
+            logger.warning(
+                "watsonx.ai chat for model %s finished with reason '%s'; "
+                "the response may be truncated or incomplete",
+                model_name,
+                finish_reason,
+            )
+
+        response_message: Dict[str, Any] = choice["message"]
+
+        tool_calls: List[Dict[str, Any]] = []
+        for tc in response_message.get("tool_calls") or []:
+            fn = tc.get("function", {}) or {}
+            args = _parse_tool_arguments(fn.get("arguments"))
+            tool_calls.append(
+                {
+                    "id": uuid.uuid4(),
+                    "type": tc.get("type", "function"),
+                    "function": {
+                        "name": fn.get("name"),
+                        "arguments": args,
+                    },
+                    "original_id": tc.get("id"),
+                }
+            )
+
+        content = response_message.get("content") or ""
+
+        if extract_reasoning and content:
+            content, reasoning = self._extract_reasoning(content)
+            if reasoning:
+                extra_args["reasoning"] = reasoning
+
+        return ChatMessage(
+            role=MessageRole(response_message.get("role", "assistant")),
+            content=content,
+            tool_calls=tool_calls,
+            extra_args=extra_args,
+        )
+
+
+DEFAULT_TEMPERATURE = 0.1
+
+
+class WatsonxChatModelSetup(BaseChatModelSetup):
+    """Chat model configuration for IBM watsonx.ai."""
+
+    temperature: float = Field(
+        default=DEFAULT_TEMPERATURE,
+        description="The temperature to use for sampling.",
+        ge=0.0,
+        le=2.0,
+    )
+    max_tokens: int | None = Field(
+        default=None,
+        description="The maximum number of tokens to generate.",
+        gt=0,
+    )
+    additional_kwargs: Dict[str, Any] = Field(
+        default_factory=dict,
+        description="Additional chat parameters for the watsonx.ai API.",
+    )
+    extract_reasoning: bool = Field(
+        default=False,
+        description="If True, extracts reasoning content from the response and 
stores it.",
+    )
+
+    def __init__(
+        self,
+        *,
+        model: str = DEFAULT_MODEL,
+        temperature: float = DEFAULT_TEMPERATURE,
+        max_tokens: int | None = None,
+        additional_kwargs: Dict[str, Any] | None = None,
+        extract_reasoning: bool = False,
+        **kwargs: Any,
+    ) -> None:
+        """Initialize the model configuration."""
+        if additional_kwargs is None:
+            additional_kwargs = {}
+        super().__init__(
+            model=model,
+            temperature=temperature,
+            max_tokens=max_tokens,
+            additional_kwargs=additional_kwargs,
+            extract_reasoning=extract_reasoning,
+            **kwargs,
+        )
+
+    @property
+    def model_kwargs(self) -> Dict[str, Any]:
+        """Return watsonx.ai model configuration."""
+        base_kwargs: Dict[str, Any] = {
+            "model": self.model,
+            "temperature": self.temperature,
+            "extract_reasoning": self.extract_reasoning,
+        }
+        if self.max_tokens is not None:
+            base_kwargs["max_tokens"] = self.max_tokens
+        if self.additional_kwargs:
+            base_kwargs["additional_kwargs"] = self.additional_kwargs
+        return base_kwargs
diff --git a/python/pyproject.toml b/python/pyproject.toml
index a9660d43..d284a6fd 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -56,6 +56,8 @@ dependencies = [
     "dashscope~=1.24.2",
     "openai>=1.66.3",
     "anthropic>=0.77.0",
+    "ibm-watsonx-ai>=1.3.42,<1.6.0;python_version<'3.11'",
+    "ibm-watsonx-ai>=1.6.0,<2;python_version>='3.11'",
     "chromadb==1.0.21",
     "mem0ai>=0.1.43,<2.0.0",
     "onnxruntime<1.24.1;python_version<'3.11'",

Reply via email to