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

gnodet pushed a commit to branch investigate-and-fix-camel-24288-add-ibm-watsonx
in repository https://gitbox.apache.org/repos/asf/camel.git

commit bc5d5b17308653b8366888f01d7fab4071005f3d
Author: Guillaume Nodet <[email protected]>
AuthorDate: Wed Jul 29 08:42:53 2026 +0200

    CAMEL-24288: Add IBM watsonx.ai support for TUI/CLI AI prompt (F8)
    
    Add watsonx as a new LlmClient.ApiType that uses the IBM watsonx.ai
    model gateway (OpenAI-compatible). The implementation:
    
    - Adds ApiType.watsonx with auto-detection via WATSONX_APIKEY and
      WATSONX_URL environment variables
    - Reuses the OpenAI wire format (generate, chatWithTools) since the
      watsonx model gateway is OpenAI-API-compatible
    - Adds watsonx-specific URL normalization for the /ml/gateway/v1 path
    - Defaults to ibm/granite-3.1-8b-instruct model and us-south region
    - Adds watsonx to the TUI AiProviderSelector and switch popup
    - Updates CLI help text in Ask and Explain commands
    - Includes unit tests for URL normalization and provider selection
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../ROOT/pages/jbang-commands/camel-jbang-ask.adoc |   4 +-
 .../pages/jbang-commands/camel-jbang-explain.adoc  |   4 +-
 .../META-INF/camel-jbang-commands-metadata.json    |   4 +-
 .../apache/camel/dsl/jbang/core/commands/Ask.java  |   4 +-
 .../camel/dsl/jbang/core/commands/Explain.java     |   5 +-
 .../camel/dsl/jbang/core/commands/LlmClient.java   |  88 +++++++++++++++-
 .../jbang/core/commands/LlmClientWatsonxTest.java  | 113 +++++++++++++++++++++
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java |   2 +-
 .../core/commands/tui/AiProviderSelector.java      |   3 +
 .../core/commands/tui/AiProviderSelectorTest.java  |  20 +++-
 10 files changed, 228 insertions(+), 19 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-ask.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-ask.adoc
index 3cbefd8280fb..a43df8daceda 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-ask.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-ask.adoc
@@ -19,8 +19,8 @@ camel ask [options]
 [cols="2,5,1,2",options="header"]
 |===
 | Option | Description | Default | Type
-| `--api-key` | API key. Also reads ANTHROPIC_API_KEY, OPENAI_API_KEY, or 
LLM_API_KEY env vars |  | String
-| `--api-type` | API type: 'ollama', 'openai', or 'anthropic' |  | ApiType
+| `--api-key` | API key. Also reads ANTHROPIC_API_KEY, OPENAI_API_KEY, 
WATSONX_APIKEY, or LLM_API_KEY env vars |  | String
+| `--api-type` | API type: 'ollama', 'openai', 'anthropic', or 'watsonx' |  | 
ApiType
 | `--max-iterations` | Maximum number of tool-calling rounds | 10 | int
 | `--model` | Model to use | DEFAULT_MODEL | String
 | `--name` | Name or PID of the Camel process. Auto-detected when exactly one 
process is running |  | String
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-explain.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-explain.adoc
index c856557ff5c3..bf1f3cacb9ba 100644
--- 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-explain.adoc
+++ 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-explain.adoc
@@ -19,8 +19,8 @@ camel explain [options]
 [cols="2,5,1,2",options="header"]
 |===
 | Option | Description | Default | Type
-| `--api-key` | API key for authentication. Also reads ANTHROPIC_API_KEY, 
OPENAI_API_KEY, or LLM_API_KEY env vars |  | String
-| `--api-type` | API type: 'ollama', 'openai' (OpenAI-compatible), or 
'anthropic' (Anthropic/Vertex AI) |  | ApiType
+| `--api-key` | API key for authentication. Also reads ANTHROPIC_API_KEY, 
OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars |  | String
+| `--api-type` | API type: 'ollama', 'openai' (OpenAI-compatible), 'anthropic' 
(Anthropic/Vertex AI), or 'watsonx' (IBM watsonx.ai) |  | ApiType
 | `--catalog-context` | Include Camel Catalog descriptions in the prompt |  | 
boolean
 | `--format` | Output format (text, markdown) | text | String
 | `--model` | Model to use | DEFAULT_MODEL | String
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index 85a31044c196..0174f9a08733 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -1,6 +1,6 @@
 {
   "commands": [
-    { "name": "ask", "fullName": "ask", "description": "Ask a question about a 
running Camel application using AI", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names": 
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY, 
OPENAI_API_KEY, or LLM_API_KEY env vars", "javaType": "java.lang.String", 
"type": "string" }, { "names": "--api-type", "description": "API type: 
'ollama', 'openai', or 'anthropic'", "javaType": "LlmClient.ApiType", "type" 
[...]
+    { "name": "ask", "fullName": "ask", "description": "Ask a question about a 
running Camel application using AI", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names": 
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY, 
OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType": 
"java.lang.String", "type": "string" }, { "names": "--api-type", "description": 
"API type: 'ollama', 'openai', 'anthropic', or 'watsonx'", "javaType":  [...]
     { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind 
source and sink Kamelets as a new Camel integration", "deprecated": true, 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": 
[ { "names": "--error-handler", "description": "Add error handler 
(none|log|sink:<endpoint>). Sink endpoints are expected in the format 
[[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet 
name.", "javaType": "java.lang.String", "type": "stri [...]
     { "name": "catalog", "fullName": "catalog", "description": "List artifacts 
from Camel Catalog", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ 
{ "names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"component", "fullName": "catalog component", "description": "List components 
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
     { "name": "cmd", "fullName": "cmd", "description": "Performs commands in 
the running Camel integrations, such as start\/stop route, or change logging 
levels.", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"browse", "fullName": "cmd browse", "description": "Browse pending messages on 
endpoints [...]
@@ -13,7 +13,7 @@
     { "name": "doc", "fullName": "doc", "description": "Shows documentation 
for kamelet, component, and other Camel resources", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogDoc", "options": [ { 
"names": "--camel-version", "description": "To use a different Camel version 
than the default version", "javaType": "java.lang.String", "type": "string" }, 
{ "names": "--download", "description": "Whether to allow automatic downloading 
JAR dependencies (over the internet [...]
     { "name": "doctor", "fullName": "doctor", "description": "Checks the 
environment and reports potential issues", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Doctor", "options": [ { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ] },
     { "name": "eval", "fullName": "eval", "description": "Evaluate Camel 
expressions and scripts", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.EvalCommand", "options": [ { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ], "subcommands": [ { "name": "expression", 
"fullName": "eval expression", "description": "Evaluates Camel expression", 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.EvalEx [...]
-    { "name": "explain", "fullName": "explain", "description": "Explain what a 
Camel route does using AI\/LLM", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Explain", "options": [ { "names": 
"--api-key", "description": "API key for authentication. Also reads 
ANTHROPIC_API_KEY, OPENAI_API_KEY, or LLM_API_KEY env vars", "javaType": 
"java.lang.String", "type": "string" }, { "names": "--api-type", "description": 
"API type: 'ollama', 'openai' (OpenAI-compatible), or 'anthropic' (A [...]
+    { "name": "explain", "fullName": "explain", "description": "Explain what a 
Camel route does using AI\/LLM", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Explain", "options": [ { "names": 
"--api-key", "description": "API key for authentication. Also reads 
ANTHROPIC_API_KEY, OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", 
"javaType": "java.lang.String", "type": "string" }, { "names": "--api-type", 
"description": "API type: 'ollama', 'openai' (OpenAI-compatible), ' [...]
     { "name": "export", "fullName": "export", "description": "Export to other 
runtimes (Camel Main, Spring Boot, or Quarkus)", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Export", "options": [ { "names": 
"--build-property", "description": "Maven build properties, ex. 
--build-property=prop1=foo", "javaType": "java.util.List", "type": "array" }, { 
"names": "--camel-spring-boot-version", "description": "Camel version to use 
with Spring Boot", "javaType": "java.lang.String", "ty [...]
     { "name": "get", "fullName": "get", "description": "Get status of Camel 
integrations", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ { 
"names": "--watch", "description": "Execute periodically and showing output 
fullscreen", "javaType": "boolean", "type": "boolean" }, { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ], "subcommands": [ { "name": "activity", 
"fullName": " [...]
     { "name": "harden", "fullName": "harden", "description": "Suggest security 
hardening for Camel routes using AI\/LLM", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Harden", "options": [ { "names": 
"--api-key", "description": "API key for authentication. Also reads 
OPENAI_API_KEY or LLM_API_KEY env vars", "javaType": "java.lang.String", 
"type": "string" }, { "names": "--api-type", "description": "API type: 'ollama' 
or 'openai' (OpenAI-compatible)", "defaultValue": "ollama", [...]
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
index 342c6eaca339..5c1866e043be 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
@@ -61,11 +61,11 @@ public class Ask extends CamelCommand {
     String url;
 
     @Option(names = { "--api-type" },
-            description = "API type: 'ollama', 'openai', or 'anthropic'")
+            description = "API type: 'ollama', 'openai', 'anthropic', or 
'watsonx'")
     LlmClient.ApiType apiType;
 
     @Option(names = { "--api-key" },
-            description = "API key. Also reads ANTHROPIC_API_KEY, 
OPENAI_API_KEY, or LLM_API_KEY env vars")
+            description = "API key. Also reads ANTHROPIC_API_KEY, 
OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars")
     String apiKey;
 
     @Option(names = { "--model" },
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
index 836afd62f164..f67d1023d7da 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
@@ -93,11 +93,11 @@ public class Explain extends CamelCommand {
     String url;
 
     @Option(names = { "--api-type" },
-            description = "API type: 'ollama', 'openai' (OpenAI-compatible), 
or 'anthropic' (Anthropic/Vertex AI)")
+            description = "API type: 'ollama', 'openai' (OpenAI-compatible), 
'anthropic' (Anthropic/Vertex AI), or 'watsonx' (IBM watsonx.ai)")
     LlmClient.ApiType apiType;
 
     @Option(names = { "--api-key" },
-            description = "API key for authentication. Also reads 
ANTHROPIC_API_KEY, OPENAI_API_KEY, or LLM_API_KEY env vars")
+            description = "API key for authentication. Also reads 
ANTHROPIC_API_KEY, OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars")
     String apiKey;
 
     @Option(names = { "--model" },
@@ -199,6 +199,7 @@ public class Explain extends CamelCommand {
         printer().printErr("  2. camel explain my-route.yaml 
--url=http://localhost:11434";);
         printer().printErr("  3. camel explain my-route.yaml 
--url=https://api.openai.com --api-type=openai --api-key=sk-...");
         printer().printErr("  4. camel explain my-route.yaml 
--api-type=anthropic  (uses ANTHROPIC_API_KEY or Vertex AI)");
+        printer().printErr("  5. camel explain my-route.yaml 
--api-type=watsonx    (uses WATSONX_APIKEY)");
     }
 
     private int explainRoute(String file, LlmClient client) throws Exception {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
index 7bb8a1f39681..4cea8915184c 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
@@ -40,7 +40,8 @@ import org.apache.camel.util.json.JsonObject;
 import org.apache.camel.util.json.Jsoner;
 
 /**
- * Shared LLM HTTP client supporting Ollama, OpenAI-compatible, and Anthropic 
(including Vertex AI) APIs.
+ * Shared LLM HTTP client supporting Ollama, OpenAI-compatible, Anthropic 
(including Vertex AI), and IBM watsonx.ai
+ * APIs.
  */
 public class LlmClient {
 
@@ -52,6 +53,8 @@ public class LlmClient {
     private static final String DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6";
     private static final String DEFAULT_OPENAI_MODEL = "gpt-4o-mini";
     private static final String DEFAULT_OLLAMA_MODEL = "llama3.2";
+    private static final String DEFAULT_WATSONX_URL = 
"https://us-south.ml.cloud.ibm.com";;
+    private static final String DEFAULT_WATSONX_MODEL = 
"ibm/granite-3.1-8b-instruct";
     private static final String DEFAULT_AZURE_API_VERSION = "2024-10-21";
     /** Last-resort Azure deployment segment when URL normalization runs 
before model detection completes. */
     private static final String DEFAULT_AZURE_DEPLOYMENT_FALLBACK = "gpt-4o";
@@ -67,7 +70,8 @@ public class LlmClient {
     public enum ApiType {
         ollama,
         openai,
-        anthropic
+        anthropic,
+        watsonx
     }
 
     enum OpenAiAuthMode {
@@ -243,13 +247,15 @@ public class LlmClient {
                 case anthropic -> tryAnthropicOrVertex();
                 case openai -> tryAzureOpenAi() || tryOpenAi();
                 case ollama -> tryInfraOllama() || tryDefaultOllama();
+                case watsonx -> tryWatsonx();
             };
         } else {
-            // auto-detect priority: anthropic → vertex → azure openai → 
openai → ollama
+            // auto-detect priority: anthropic → vertex → azure openai → 
openai → watsonx → ollama
             found = tryAnthropicApiKey()
                     || tryVertexAi()
                     || tryAzureOpenAi()
                     || tryOpenAi()
+                    || tryWatsonx()
                     || tryInfraOllama()
                     || tryDefaultOllama();
         }
@@ -291,6 +297,11 @@ public class LlmClient {
                     resolveOllamaModel();
                 }
             }
+            case watsonx -> {
+                if (model == null || model.isBlank() || 
isGenericPlaceholderModel(model)) {
+                    model = DEFAULT_WATSONX_MODEL;
+                }
+            }
         }
     }
 
@@ -307,6 +318,14 @@ public class LlmClient {
             // Vertex AI uses gcloud token, not API key
             return null;
         }
+        if (apiType == ApiType.watsonx) {
+            String key = System.getenv("WATSONX_APIKEY");
+            if (key != null && !key.isBlank()) {
+                apiKey = key;
+                return key;
+            }
+            return null;
+        }
         if (apiType == ApiType.openai && openAiAuthMode == 
OpenAiAuthMode.api_key) {
             String key = System.getenv("AZURE_OPENAI_API_KEY");
             if (key != null && !key.isBlank()) {
@@ -333,7 +352,7 @@ public class LlmClient {
     String generate(String systemPrompt, String userPrompt) {
         return switch (apiType) {
             case ollama -> generateOllama(systemPrompt, userPrompt);
-            case openai -> generateOpenAi(systemPrompt, userPrompt);
+            case openai, watsonx -> generateOpenAi(systemPrompt, userPrompt);
             case anthropic -> generateAnthropic(systemPrompt, userPrompt);
         };
     }
@@ -343,7 +362,7 @@ public class LlmClient {
     public ChatResponse chatWithTools(String systemPrompt, List<Message> 
messages, List<ToolDef> tools) {
         return switch (apiType) {
             case ollama -> chatOllamaFormat(systemPrompt, messages, tools);
-            case openai -> chatOpenAiFormat(systemPrompt, messages, tools);
+            case openai, watsonx -> chatOpenAiFormat(systemPrompt, messages, 
tools);
             case anthropic -> chatAnthropicFormat(systemPrompt, messages, 
tools);
         };
     }
@@ -365,6 +384,7 @@ public class LlmClient {
             case ollama -> listOllamaModels();
             case openai -> listOpenAiModels();
             case anthropic -> isVertexAi() ? List.of() : listAnthropicModels();
+            case watsonx -> listWatsonxModels();
         };
     }
 
@@ -398,6 +418,16 @@ public class LlmClient {
         return extractStringList(response, "data", "id");
     }
 
+    private List<String> listWatsonxModels() {
+        String resolvedKey = resolveApiKey();
+        Map<String, String> headers = new HashMap<>();
+        if (resolvedKey != null && !resolvedKey.isBlank()) {
+            headers.put("Authorization", "Bearer " + resolvedKey);
+        }
+        JsonObject response = sendGetRequest(normalizeWatsonxModelsUrl(url), 
headers);
+        return extractStringList(response, "data", "id");
+    }
+
     private static List<String> extractStringList(JsonObject response, String 
arrayField, String nameField) {
         if (response == null) {
             return List.of();
@@ -1455,6 +1485,21 @@ public class LlmClient {
         return false;
     }
 
+    private boolean tryWatsonx() {
+        String key = System.getenv("WATSONX_APIKEY");
+        if (key == null || key.isBlank()) {
+            return false;
+        }
+        apiType = ApiType.watsonx;
+        apiKey = key;
+        String endpoint = System.getenv("WATSONX_URL");
+        url = (endpoint != null && !endpoint.isBlank()) ? 
stripTrailingSlash(endpoint) : DEFAULT_WATSONX_URL;
+        if (model == null || isGenericPlaceholderModel(model)) {
+            model = DEFAULT_WATSONX_MODEL;
+        }
+        return true;
+    }
+
     /**
      * Picks a default Ollama model when none was configured (for example 
after a provider switch). Rather than assume a
      * hardcoded model that may not be installed, this queries the installed 
models and prefers {@code llama3.2} when
@@ -1665,6 +1710,9 @@ public class LlmClient {
         if (isAzureOpenAiEndpoint(u)) {
             return normalizeAzureOpenAiChatUrl(u);
         }
+        if (apiType == ApiType.watsonx) {
+            return normalizeWatsonxChatUrl(u);
+        }
         if (!u.endsWith("/v1/chat/completions")) {
             u = u.endsWith("/v1") ? u : u + "/v1";
             u = u + "/chat/completions";
@@ -1672,6 +1720,36 @@ public class LlmClient {
         return u;
     }
 
+    // ---- watsonx URL helpers ----
+
+    static final String WATSONX_GATEWAY_CHAT_PATH = 
"/ml/gateway/v1/chat/completions";
+    static final String WATSONX_GATEWAY_MODELS_PATH = "/ml/gateway/v1/models";
+
+    String normalizeWatsonxChatUrl(String endpoint) {
+        String u = stripTrailingSlash(endpoint);
+        if (u.endsWith(WATSONX_GATEWAY_CHAT_PATH)) {
+            return u;
+        }
+        // Strip any partial gateway path so we can rebuild from the base
+        int gatewayIdx = u.indexOf("/ml/gateway");
+        if (gatewayIdx > 0) {
+            u = u.substring(0, gatewayIdx);
+        }
+        return u + WATSONX_GATEWAY_CHAT_PATH;
+    }
+
+    String normalizeWatsonxModelsUrl(String endpoint) {
+        String u = stripTrailingSlash(endpoint);
+        if (u.endsWith(WATSONX_GATEWAY_MODELS_PATH)) {
+            return u;
+        }
+        int gatewayIdx = u.indexOf("/ml/gateway");
+        if (gatewayIdx > 0) {
+            u = u.substring(0, gatewayIdx);
+        }
+        return u + WATSONX_GATEWAY_MODELS_PATH;
+    }
+
     // ---- Error handling ----
 
     private void handleErrorStatus(int statusCode, String body) {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientWatsonxTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientWatsonxTest.java
new file mode 100644
index 000000000000..f70eb64ca101
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientWatsonxTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.camel.dsl.jbang.core.commands;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for watsonx.ai URL normalization and provider configuration in 
{@link LlmClient}.
+ */
+class LlmClientWatsonxTest {
+
+    @Test
+    void normalizeWatsonxChatUrlAppendsGatewayPath() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.watsonx);
+
+        
assertThat(client.normalizeWatsonxChatUrl("https://us-south.ml.cloud.ibm.com";))
+                
.isEqualTo("https://us-south.ml.cloud.ibm.com/ml/gateway/v1/chat/completions";);
+    }
+
+    @Test
+    void normalizeWatsonxChatUrlStripsTrailingSlash() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.watsonx);
+
+        
assertThat(client.normalizeWatsonxChatUrl("https://us-south.ml.cloud.ibm.com/";))
+                
.isEqualTo("https://us-south.ml.cloud.ibm.com/ml/gateway/v1/chat/completions";);
+    }
+
+    @Test
+    void normalizeWatsonxChatUrlPreservesFullGatewayUrl() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.watsonx);
+
+        String fullUrl = 
"https://us-south.ml.cloud.ibm.com/ml/gateway/v1/chat/completions";;
+        assertThat(client.normalizeWatsonxChatUrl(fullUrl)).isEqualTo(fullUrl);
+    }
+
+    @Test
+    void normalizeWatsonxChatUrlRebuildsFromPartialGatewayPath() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.watsonx);
+
+        
assertThat(client.normalizeWatsonxChatUrl("https://us-south.ml.cloud.ibm.com/ml/gateway";))
+                
.isEqualTo("https://us-south.ml.cloud.ibm.com/ml/gateway/v1/chat/completions";);
+    }
+
+    @Test
+    void normalizeWatsonxModelsUrlAppendsGatewayModelsPath() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.watsonx);
+
+        
assertThat(client.normalizeWatsonxModelsUrl("https://us-south.ml.cloud.ibm.com";))
+                
.isEqualTo("https://us-south.ml.cloud.ibm.com/ml/gateway/v1/models";);
+    }
+
+    @Test
+    void normalizeWatsonxModelsUrlPreservesFullModelsUrl() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.watsonx);
+
+        String fullUrl = "https://eu-de.ml.cloud.ibm.com/ml/gateway/v1/models";;
+        
assertThat(client.normalizeWatsonxModelsUrl(fullUrl)).isEqualTo(fullUrl);
+    }
+
+    @Test
+    void normalizeOpenAiUrlDelegatesToWatsonxForWatsonxApiType() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.watsonx);
+
+        
assertThat(client.normalizeOpenAiUrl("https://us-south.ml.cloud.ibm.com";))
+                
.isEqualTo("https://us-south.ml.cloud.ibm.com/ml/gateway/v1/chat/completions";);
+    }
+
+    @Test
+    void normalizeWatsonxChatUrlWorksDifferentRegions() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.watsonx);
+
+        
assertThat(client.normalizeWatsonxChatUrl("https://eu-de.ml.cloud.ibm.com";))
+                
.isEqualTo("https://eu-de.ml.cloud.ibm.com/ml/gateway/v1/chat/completions";);
+        
assertThat(client.normalizeWatsonxChatUrl("https://jp-tok.ml.cloud.ibm.com";))
+                
.isEqualTo("https://jp-tok.ml.cloud.ibm.com/ml/gateway/v1/chat/completions";);
+    }
+
+    @Test
+    void watsonxApiTypeExistsInEnum() {
+        
assertThat(LlmClient.ApiType.valueOf("watsonx")).isEqualTo(LlmClient.ApiType.watsonx);
+    }
+
+    @Test
+    void normalizeOpenAiUrlProducesGatewayPathNotStandardOpenAiPath() {
+        LlmClient client = LlmClient.create()
+                .withApiType(LlmClient.ApiType.watsonx)
+                .withUrl("https://us-south.ml.cloud.ibm.com";)
+                .withModel("ibm/granite-3.1-8b-instruct")
+                .withApiKey("test-key");
+
+        // The normalized URL should use the watsonx gateway path, not the 
standard /v1/chat/completions
+        String normalized = 
client.normalizeOpenAiUrl("https://us-south.ml.cloud.ibm.com";);
+        assertThat(normalized)
+                
.isEqualTo("https://us-south.ml.cloud.ibm.com/ml/gateway/v1/chat/completions";)
+                .startsWith("https://us-south.ml.cloud.ibm.com/ml/gateway/";);
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index cf4baac492d2..389efab6df1c 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -291,7 +291,7 @@ class AiPanel {
             client = created;
             if (!client.detectEndpoint()) {
                 initError
-                        = "No LLM service reachable. Set ANTHROPIC_API_KEY, 
AZURE_OPENAI_*, OPENAI_API_KEY, or start Ollama.";
+                        = "No LLM service reachable. Set ANTHROPIC_API_KEY, 
AZURE_OPENAI_*, OPENAI_API_KEY, WATSONX_APIKEY, or start Ollama.";
                 client = null;
                 return;
             }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java
index e1dbc4e26907..b12ef6af0f73 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java
@@ -55,6 +55,9 @@ final class AiProviderSelector {
         if (!"ollama".equals(defaultProvider)) {
             choices.add(new AiProviderSwitchPopup.ProviderChoice("ollama", "", 
"", false));
         }
+        if (!"watsonx".equals(defaultProvider)) {
+            choices.add(new AiProviderSwitchPopup.ProviderChoice("watsonx", 
"", "", false));
+        }
         return choices;
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java
index 2b45f4b65c31..bd9087afdd18 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java
@@ -58,7 +58,7 @@ class AiProviderSelectorTest {
 
         List<AiProviderSwitchPopup.ProviderChoice> choices = 
selector.buildChoices();
 
-        assertEquals(List.of("auto", "anthropic", "openai", "ollama"),
+        assertEquals(List.of("auto", "anthropic", "openai", "ollama", 
"watsonx"),
                 
choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList(),
                 "all known providers must be offered, even without a detected 
API key, so they remain selectable");
         assertTrue(choices.get(0).persistedDefault());
@@ -73,7 +73,7 @@ class AiProviderSelectorTest {
 
         List<AiProviderSwitchPopup.ProviderChoice> choices = 
selector.buildChoices();
 
-        assertEquals(List.of("anthropic", "openai", "ollama"),
+        assertEquals(List.of("anthropic", "openai", "ollama", "watsonx"),
                 
choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList(),
                 "anthropic must not be listed twice when it's already the 
default");
     }
@@ -87,7 +87,20 @@ class AiProviderSelectorTest {
 
         List<AiProviderSwitchPopup.ProviderChoice> choices = 
selector.buildChoices();
 
-        assertEquals(List.of("ollama", "anthropic", "openai"),
+        assertEquals(List.of("ollama", "anthropic", "openai", "watsonx"),
+                
choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList());
+    }
+
+    @Test
+    void watsonxDefaultIsNotDuplicated(@TempDir Path tempDir) {
+        useHome(tempDir);
+        TuiSettings settings = TuiSettings.load();
+        settings.setAiProvider("watsonx");
+        settings.save();
+
+        List<AiProviderSwitchPopup.ProviderChoice> choices = 
selector.buildChoices();
+
+        assertEquals(List.of("watsonx", "anthropic", "openai", "ollama"),
                 
choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList());
     }
 
@@ -124,5 +137,6 @@ class AiProviderSelectorTest {
         assertTrue(ex.getMessage().contains("ollama"));
         assertTrue(ex.getMessage().contains("openai"));
         assertTrue(ex.getMessage().contains("anthropic"));
+        assertTrue(ex.getMessage().contains("watsonx"));
     }
 }

Reply via email to