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")); } }
