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