This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-23688-llm-url in repository https://gitbox.apache.org/repos/asf/camel.git
commit c3d56648335370081c70e4e586eb6828d86f5efb Author: Claus Ibsen <[email protected]> AuthorDate: Sat Oct 17 08:06:21 2026 +0200 CAMEL-23688: camel-jbang - An LLM given with --url is the only one used When the LLM given with --url did not answer, LlmClient.detectEndpoint went on to detect another provider: the Ollama of camel infra or of the host, or a hosted provider with an API key in the environment, and sent the prompts there. Now an explicit URL is the only endpoint the client tries, so camel ask, camel explain and camel overview --ai fail, and the AI panel of the TUI says which configured URL does not answer. Without a URL the provider is detected as before (camel doctor and the Ollama tab of the TUI probe without one). The tests no longer depend on whether the host runs Ollama: an Ollama of camel infra (a pid file and a fake server) is always there for the fallback to find. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 7 ++ .../ROOT/pages/camel-jbang-ai-providers.adoc | 4 + .../camel/dsl/jbang/core/commands/LlmClient.java | 6 +- .../core/commands/LlmClientExplicitUrlTest.java | 98 ++++++++++++++++++++++ .../dsl/jbang/core/commands/OverviewTest.java | 4 +- .../jbang/core/common/OllamaDoctorSupportTest.java | 25 ++---- .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 7 +- 7 files changed, 126 insertions(+), 25 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 81cf732576ea..28697555d493 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -521,6 +521,13 @@ or follows, like Enter does. * `camel ask` with a question prints only the answer on stdout. The model it uses, the integration it asks about, the note about a small model, and the output of `--show-tools` and `--show-stats` go to stderr. +=== camel-jbang - an LLM given with --url is the only one used + +`camel ask`, `camel explain` and `camel overview --ai` fail when the LLM given with `--url` does not answer: they went +on to another provider, such as the Ollama of the host, or a hosted provider with an API key in the environment, and +sent the prompts there. The AI panel of the Camel TUI does the same with the URL of its settings. Without `--url` the +provider is detected as before. + === camel-jbang - exit codes and errors of more commands * `camel export` does not clean the export directory when it is the current directory, or one that contains it, unless diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai-providers.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai-providers.adoc index 945508eb7c9e..52a2eb131920 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-ai-providers.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-ai-providers.adoc @@ -30,6 +30,10 @@ camel ask "check health" --api-type=gemini --model=gemini-2.0-flash camel ask "check health" --api-type=ollama --model=llama3.1 ---- +With `--url`, the CLI uses that endpoint and no other: when it does not answer, the command fails instead of going +on to another provider, so the prompts never reach a provider you did not choose. The same holds for the URL set in +the settings of the Camel TUI. + NOTE: `camel harden` only detects Ollama (from `camel infra` or at `localhost:11434`). For another provider, give it the endpoint: `--url=https://api.openai.com --api-type=openai --api-key=sk-...` (any OpenAI-compatible API). 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 e83e8d02a1e6..2f8d4206f88b 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 @@ -342,8 +342,10 @@ public class LlmClient { public boolean detectEndpoint() { boolean found; - if (tryExplicitUrl()) { - found = true; + if (url != null && !url.isBlank()) { + // the endpoint the user named, and only that one: never another provider, which may be a hosted one the + // user did not choose to send the prompts to + found = tryExplicitUrl(); } else if (apiType != null) { found = switch (apiType) { case anthropic -> tryAnthropicOrVertex(); diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientExplicitUrlTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientExplicitUrlTest.java new file mode 100644 index 000000000000..6f12727ddd23 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientExplicitUrlTest.java @@ -0,0 +1,98 @@ +/* + * 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 java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.camel.dsl.jbang.core.commands.LlmClient.ApiType; +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An endpoint the user names with --url is the only one the client uses: when it does not answer, the client does not + * go on to another provider. Here there is always another one to find, an Ollama started with camel infra run. + */ +class LlmClientExplicitUrlTest { + + private static final String NOTHING_THERE = "http://127.0.0.1:1"; + + @TempDir + Path home; + + private Path previousHome; + private FakeLlmServer infraOllama; + + @BeforeEach + void start() throws Exception { + previousHome = CommandLineHelper.getHomeDir(); + CommandLineHelper.useHomeDir(home.toString()); + Files.createDirectories(CommandLineHelper.getCamelDir()); + // what camel infra run ollama leaves for the CLI to find + infraOllama = new FakeLlmServer("unused"); + Files.writeString(CommandLineHelper.getCamelDir().resolve("infra-ollama-4711.json"), + "{\"baseUrl\":\"" + infraOllama.url() + "\"}"); + } + + @AfterEach + void stop() { + infraOllama.close(); + CommandLineHelper.useHomeDir(previousHome.toString()); + } + + @Test + void withoutAUrlTheOllamaOfCamelInfraIsFound() { + LlmClient client = LlmClient.create().withApiType(ApiType.ollama); + + assertThat(client.detectEndpoint()).isTrue(); + assertThat(client.endpointUrl()).isEqualTo(infraOllama.url()); + } + + @Test + void anUnreachableUrlOfAnApiType() { + LlmClient client = LlmClient.create().withApiType(ApiType.ollama).withUrl(NOTHING_THERE); + + assertThat(client.detectEndpoint()).isFalse(); + // not the url of another Ollama + assertThat(client.endpointUrl()).isEqualTo(NOTHING_THERE); + } + + @Test + void anUnreachableUrlWithoutAnApiType() { + LlmClient client = LlmClient.create().withUrl(NOTHING_THERE); + + assertThat(client.detectEndpoint()).isFalse(); + assertThat(client.endpointUrl()).isEqualTo(NOTHING_THERE); + assertThat(client.apiType()).isNull(); + } + + @Test + void aUrlThatAnswersIsUsed() throws Exception { + try (FakeLlmServer server = new FakeLlmServer("unused")) { + LlmClient client = LlmClient.create().withUrl(server.url()); + + assertThat(client.detectEndpoint()).isTrue(); + assertThat(client.endpointUrl()).isEqualTo(server.url()); + assertThat(client.apiType()).isEqualTo(ApiType.ollama); + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/OverviewTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/OverviewTest.java index 823e90ca27bf..0d7226d8134b 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/OverviewTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/OverviewTest.java @@ -41,7 +41,6 @@ import picocli.CommandLine; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assumptions.assumeFalse; /** * camel overview: the overview of the routes of a project, the summary file, and what an LLM adds to it (a local server @@ -181,8 +180,7 @@ class OverviewTest extends CamelCommandBaseTestSupport { @Test void aiWithoutAnLlmIsAFailure() { - // an --url that does not answer is not the end of it: the client goes on to the Ollama of the host (CAMEL-24772) - assumeFalse(LlmClient.create().withApiType(LlmClient.ApiType.ollama).detectEndpoint(), "the host runs Ollama"); + // an --url that does not answer is a failure: the client does not go on to another provider Overview command = command("--ai", "--api-type=ollama", "--url=http://127.0.0.1:1"); assertThatThrownBy(command::doCall) diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/OllamaDoctorSupportTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/OllamaDoctorSupportTest.java index 5041d7e854f7..f9832e580ae3 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/OllamaDoctorSupportTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/OllamaDoctorSupportTest.java @@ -41,29 +41,18 @@ class OllamaDoctorSupportTest { } /** - * {@code detect} is a discovery: an explicit URL that does not answer is not the end of it, the client goes on to - * the {@code camel infra run ollama} PID files and the default {@code localhost:11434}, which is what a doctor - * probe is for. So the outcome for an unreachable URL depends on the host: nothing found when the host has no - * Ollama, the host's own Ollama otherwise, and never the unreachable URL itself. Asserting only "not running" - * failed on every machine with Ollama running (CAMEL-24772). + * An explicit URL that does not answer is not running, also on a host that runs Ollama: the client does not go on + * to the {@code camel infra run ollama} PID files or the default {@code localhost:11434}, which only a probe + * without a URL looks for. */ @Test - void anUnreachableUrlFallsBackToDiscoveryOfTheHostsOwnOllama() { - LlmClient discovery = LlmClient.create().withApiType(LlmClient.ApiType.ollama); - boolean hostHasOllama = discovery.detectEndpoint(); - + void anUnreachableUrlIsNotRunning() { LlmClient client = LlmClient.create().withApiType(LlmClient.ApiType.ollama).withUrl("http://127.0.0.1:1"); OllamaDoctorSupport.Status status = OllamaDoctorSupport.detect(client); - if (hostHasOllama) { - assertThat(status.running()).isTrue(); - assertThat(status.baseUrl()).isEqualTo(discovery.endpointUrl()).isNotEqualTo("http://127.0.0.1:1"); - assertThat(status.models()).isNotNull(); - } else { - assertThat(status.running()).isFalse(); - assertThat(status.baseUrl()).isNull(); - assertThat(status.models()).isEmpty(); - } + assertThat(status.running()).isFalse(); + assertThat(status.baseUrl()).isNull(); + assertThat(status.models()).isEmpty(); } @Test 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 3c3400e18c5d..823241088841 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 @@ -644,9 +644,12 @@ class AiPanel { toolSet.setMode(normalizeToolMode(TuiSettings.load().getAiTools())); } client = created; + // a url that is set is the only one tried, so say which one does not answer + String configuredUrl = client.endpointUrl(); if (!client.detectEndpoint()) { - initError - = "No LLM service reachable. Set ANTHROPIC_API_KEY, AZURE_OPENAI_*, GEMINI_API_KEY, OPENAI_API_KEY, WATSONX_APIKEY, or start Ollama."; + initError = configuredUrl != null && !configuredUrl.isBlank() + ? "No LLM service reachable at " + configuredUrl + " (the URL of the AI settings)." + : "No LLM service reachable. Set ANTHROPIC_API_KEY, AZURE_OPENAI_*, GEMINI_API_KEY, OPENAI_API_KEY, WATSONX_APIKEY, or start Ollama."; client = null; return; }
