This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 204a047bbed4 CAMEL-24288: Add IBM watsonx.ai support for TUI/CLI AI
prompt (F8)
204a047bbed4 is described below
commit 204a047bbed4354a5c0259f354deae37fbc6f2d1
Author: Guillaume Nodet <[email protected]>
AuthorDate: Fri Jul 31 11:04:08 2026 +0200
CAMEL-24288: Add IBM watsonx.ai support for TUI/CLI AI prompt (F8)
Add watsonx as a first-class AI provider in LlmClient (new ApiType.watsonx),
with auto-detection via WATSONX_APIKEY/WATSONX_URL env vars, Bearer token
auth,
OpenAI-compatible model gateway URL normalization, and default model
ibm/granite-4-1-8b-instruct. Integrate into TUI provider selector, update
Ask/Explain CLI help, and add 10 unit tests for URL normalization.
Closes #25208
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
.../ROOT/pages/jbang-commands/camel-jbang-ask.adoc | 6 +-
.../pages/jbang-commands/camel-jbang-explain.adoc | 6 +-
.../META-INF/camel-jbang-commands-metadata.json | 4 +-
.../apache/camel/dsl/jbang/core/commands/Ask.java | 6 +-
.../camel/dsl/jbang/core/commands/Explain.java | 7 +-
.../camel/dsl/jbang/core/commands/LlmClient.java | 84 ++++++++++++++-
.../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(+), 23 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..cf4dcf5f5f86 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,15 +19,15 @@ 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
| `--show-stats` | Show token usage and elapsed time after response | |
boolean
| `--show-tools` | Show tool calls and results as they happen | | boolean
| `--timeout` | Timeout in seconds for LLM response | 120 | int
-| `--url` | LLM API endpoint URL. Auto-detected if not specified. | | String
+| `--url` | LLM API endpoint URL. Auto-detected if not specified. Also reads
AZURE_OPENAI_ENDPOINT or WATSONX_URL env vars | | String
| `--verbose` | Print debug information: HTTP requests, responses, and parsed
results | | boolean
| `-h,--help` | Display the help and sub-commands | | boolean
|===
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..3a49a29f7b07 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
@@ -29,7 +29,7 @@ camel explain [options]
| `--system-prompt` | Custom system prompt | | String
| `--temperature` | Temperature for response generation (0.0-2.0) | 0.7 |
double
| `--timeout` | Timeout in seconds for LLM response | 120 | int
-| `--url` | LLM API endpoint URL. Auto-detected from 'camel infra' for Ollama
if not specified. | | String
+| `--url` | LLM API endpoint URL. Auto-detected from 'camel infra' for Ollama
if not specified. Also reads AZURE_OPENAI_ENDPOINT or WATSONX_URL env vars | |
String
| `--verbose,-v` | Include detailed technical information | | boolean
| `-h,--help` | Display the help and sub-commands | | boolean
|===
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..bc45e949846b 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..25a4496e5dc0 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
@@ -57,15 +57,15 @@ public class Ask extends CamelCommand {
List<String> question;
@Option(names = { "--url" },
- description = "LLM API endpoint URL. Auto-detected if not
specified.")
+ description = "LLM API endpoint URL. Auto-detected if not
specified. Also reads AZURE_OPENAI_ENDPOINT or WATSONX_URL env vars")
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..a37f0bee4cae 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
@@ -89,15 +89,15 @@ public class Explain extends CamelCommand {
List<String> files;
@Option(names = { "--url" },
- description = "LLM API endpoint URL. Auto-detected from 'camel
infra' for Ollama if not specified.")
+ description = "LLM API endpoint URL. Auto-detected from 'camel
infra' for Ollama if not specified. Also reads AZURE_OPENAI_ENDPOINT or
WATSONX_URL env vars")
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 and WATSONX_URL)");
}
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..175129681fbb 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-4-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,12 @@ public class LlmClient {
return extractStringList(response, "data", "id");
}
+ private List<String> listWatsonxModels() {
+ Map<String, String> headers = buildOpenAiAuthHeaders(resolveApiKey());
+ 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 +1481,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 +1706,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 +1716,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..cc5e370ec828
--- /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-4-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"));
}
}