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

davsclaus 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 a7af64a3ca11 CAMEL-23688: camel-jbang - An LLM given with --url is the 
only one used (#27994)
a7af64a3ca11 is described below

commit a7af64a3ca1132a15fabdc366d581d7ec0401188
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 17 10:03:20 2026 +0200

    CAMEL-23688: camel-jbang - An LLM given with --url is the only one used 
(#27994)
    
    An LLM given with --url (or the URL of the AI settings of Camel TUI) is the 
only one tried: when it does not answer, the command fails rather than falling 
back to the discovery of other providers, which may be a hosted one the user 
did not choose to send the prompts to. Without a URL the provider is detected 
as before. The AI panel of Camel TUI says which URL does not answer (start the 
LLM there, or Ctrl+P for another provider), rather than the setup guide for 
when no provider is found.
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[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 |  8 +-
 .../core/commands/tui/AiProviderSelector.java      | 20 +++++
 .../core/commands/tui/AiProviderSelectorTest.java  | 39 +++++++++
 9 files changed, 183 insertions(+), 28 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 974fba740871..f1cc998b316e 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..55034f71bd20 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,13 +644,11 @@ class AiPanel {
                 
toolSet.setMode(normalizeToolMode(TuiSettings.load().getAiTools()));
             }
             client = created;
-            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 = AiProviderSelector.detectEndpoint(client);
+            if (initError != null) {
                 client = null;
                 return;
             }
-            initError = null;
             // know the Ollama window before the first question instead of 
resolving it in front of the first request
             client.resolveOllamaContextWindowInBackground();
             messages = new ArrayList<>();
@@ -2041,7 +2039,7 @@ class AiPanel {
         StringBuilder md = new StringBuilder();
 
         if (initError != null) {
-            if (initError.startsWith("No LLM service reachable")) {
+            if (initError.startsWith(AiProviderSelector.NO_PROVIDER)) {
                 md.append(buildAiSetupGuide());
             } else {
                 md.append("**Error:** ").append(initError).append("\n\n");
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 e65678a61ea8..cd367ab9245f 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
@@ -38,6 +38,8 @@ final class AiProviderSelector {
 
     static final String ACP_PREFIX = "acp:";
     static final String ACP_CUSTOM = "acp:custom";
+    /** How the error starts when no provider is found, for which the panel 
shows the setup guide. */
+    static final String NO_PROVIDER = "No LLM service reachable";
     private static final String NPX_HINT = "npx not found: install Node.js 22 
or newer (https://nodejs.org) and try again.";
 
     /**
@@ -220,6 +222,24 @@ final class AiProviderSelector {
         }
     }
 
+    /**
+     * Detects the endpoint of the client: null when it has one, otherwise the 
error for the panel. A URL that is set is
+     * the only one tried, so the error then says that it does not answer, 
rather than how to set up a provider.
+     */
+    static String detectEndpoint(LlmClient client) {
+        // read before detectEndpoint(), which sets the url of the provider it 
detects when none is set
+        String url = client.endpointUrl();
+        if (client.detectEndpoint()) {
+            return null;
+        }
+        if (url != null && !url.isBlank()) {
+            return "The LLM at " + url + " does not answer. A URL that is set 
is the only one tried: start the LLM there,"
+                   + " or press Ctrl+P to choose another provider.";
+        }
+        return NO_PROVIDER
+               + ". Set ANTHROPIC_API_KEY, AZURE_OPENAI_*, GEMINI_API_KEY, 
OPENAI_API_KEY, WATSONX_APIKEY, or start Ollama.";
+    }
+
     private static LlmClient.ApiType parseApiType(String provider) {
         try {
             return LlmClient.ApiType.valueOf(provider.replace('-', '_'));
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 c621d51f8650..a146f917a4e7 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
@@ -16,10 +16,15 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
 
+import com.sun.net.httpserver.HttpServer;
 import org.apache.camel.dsl.jbang.core.commands.LlmClient;
 import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
 import org.junit.jupiter.api.AfterEach;
@@ -229,4 +234,38 @@ class AiProviderSelectorTest {
         assertEquals(npx.toString(), 
AiProviderSelector.resolveExecutable(tempDir.resolve("fakenpx").toString(), 
path));
         
assertNull(AiProviderSelector.resolveExecutable("definitely-not-a-real-binary-42",
 path));
     }
+
+    @Test
+    void aUrlThatDoesNotAnswerIsTheError() {
+        LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.ollama).withUrl("http://127.0.0.1:1";);
+
+        String error = AiProviderSelector.detectEndpoint(client);
+
+        assertEquals("The LLM at http://127.0.0.1:1 does not answer. A URL 
that is set is the only one tried: start the LLM"
+                     + " there, or press Ctrl+P to choose another provider.",
+                error);
+        assertFalse(error.startsWith(AiProviderSelector.NO_PROVIDER), "the 
panel shows the error, not the setup guide");
+    }
+
+    @Test
+    void aUrlThatAnswersIsNoError() throws IOException {
+        // plays Ollama: its models on every path
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/", exchange -> {
+            byte[] body = 
"{\"models\":[{\"name\":\"qwen3\"}]}".getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, body.length);
+            try (OutputStream out = exchange.getResponseBody()) {
+                out.write(body);
+            }
+        });
+        server.start();
+        try {
+            LlmClient client = 
LlmClient.create().withApiType(LlmClient.ApiType.ollama)
+                    .withUrl("http://127.0.0.1:"; + 
server.getAddress().getPort());
+
+            assertNull(AiProviderSelector.detectEndpoint(client));
+        } finally {
+            server.stop(0);
+        }
+    }
 }

Reply via email to