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 5d602acb7d0c CAMEL-23978: Fix AI panel cancellation, model resolution,
and model listing
5d602acb7d0c is described below
commit 5d602acb7d0c29db8a1cd732da112c6e33a57712
Author: Adriano Machado <[email protected]>
AuthorDate: Sun Jul 12 16:11:30 2026 -0400
CAMEL-23978: Fix AI panel cancellation, model resolution, and model listing
Fix four defects in the TUI AI panel: overlapping Esc cancellation now stops
both CLI and LLM independently instead of orphaning the agent thread;
provider
switch assigns a default model for OpenAI (gpt-4o-mini) and Ollama (queries
installed models) so requests no longer fail with a null model; /model
listing
runs off the event thread to avoid freezing the TUI on slow providers; and
normalizeOpenAiModelsUrl strips /chat/completions before deriving
/v1/models.
Closes #24608
Co-Authored-By: Claude Code (Claude Opus 4.8) <[email protected]>
---
.../camel/dsl/jbang/core/commands/LlmClient.java | 57 ++++++++++++++++-
.../core/commands/LlmClientListModelsTest.java | 73 ++++++++++++++++++++++
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 28 ++++++++-
.../core/commands/tui/AiSlashCommandRegistry.java | 39 +++++++-----
.../dsl/jbang/core/commands/tui/AiPanelTest.java | 63 +++++++++++++++++--
5 files changed, 235 insertions(+), 25 deletions(-)
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 f31fddd00283..f14196231890 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
@@ -50,6 +50,8 @@ public class LlmClient {
private static final String ANTHROPIC_VERSION = "2023-06-01";
private static final String VERTEX_ANTHROPIC_VERSION = "vertex-2023-10-16";
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 int CONNECT_TIMEOUT_SECONDS = 10;
private static final int HEALTH_CHECK_TIMEOUT_SECONDS = 5;
@@ -238,12 +240,41 @@ public class LlmClient {
|| tryInfraOllama()
|| tryDefaultOllama();
}
- if (found && apiType == ApiType.ollama && "llama3.2".equals(model)) {
- resolveOllamaModel();
+ if (found) {
+ applyDefaultModel();
}
return found;
}
+ /**
+ * Ensures a model is set once an endpoint is detected. A provider switch
(or a session with no persisted model)
+ * reaches detection with a null model; without a per-provider default the
request would be sent with a null model
+ * and rejected. Anthropic detection already assigns its default inline
(also replacing the generic {@code llama3.2}
+ * placeholder that the CLI commands pass); this fills the same gap for
the OpenAI and Ollama providers, and
+ * upgrades Ollama to an installed model with better tool-calling support
when only the placeholder is in effect.
+ */
+ private void applyDefaultModel() {
+ switch (apiType) {
+ case anthropic -> {
+ if (model == null || model.isBlank()) {
+ model = DEFAULT_ANTHROPIC_MODEL;
+ }
+ }
+ case openai -> {
+ if (model == null || model.isBlank()) {
+ model = DEFAULT_OPENAI_MODEL;
+ }
+ }
+ case ollama -> {
+ if (model == null || model.isBlank()) {
+ model = resolveDefaultOllamaModel();
+ } else if (DEFAULT_OLLAMA_MODEL.equals(model)) {
+ resolveOllamaModel();
+ }
+ }
+ }
+ }
+
String resolveApiKey() {
if (apiKey != null && !apiKey.isBlank()) {
return apiKey;
@@ -350,6 +381,11 @@ public class LlmClient {
String normalizeOpenAiModelsUrl(String endpoint) {
String u = endpoint.endsWith("/") ? endpoint.substring(0,
endpoint.length() - 1) : endpoint;
+ // A configured full chat endpoint (.../v1/chat/completions) shares
the same /v1 base as the models
+ // endpoint, so strip the chat suffix rather than appending /v1/models
onto it.
+ if (u.endsWith("/v1/chat/completions")) {
+ u = u.substring(0, u.length() - "/chat/completions".length());
+ }
if (!u.endsWith("/v1/models")) {
u = u.endsWith("/v1") ? u : u + "/v1";
u = u + "/models";
@@ -1332,6 +1368,23 @@ public class LlmClient {
return false;
}
+ /**
+ * 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
+ * present, otherwise the first installed model. Falls back to the {@code
llama3.2} placeholder only when the list
+ * cannot be retrieved, so the model is never left null.
+ */
+ private String resolveDefaultOllamaModel() {
+ List<String> available = listOllamaModels();
+ if (available.isEmpty()) {
+ return DEFAULT_OLLAMA_MODEL;
+ }
+ return available.stream()
+ .filter(name -> name.equals(DEFAULT_OLLAMA_MODEL) ||
name.startsWith(DEFAULT_OLLAMA_MODEL + ":"))
+ .findFirst()
+ .orElse(available.get(0));
+ }
+
private void resolveOllamaModel() {
try {
HttpRequest request = HttpRequest.newBuilder()
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientListModelsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientListModelsTest.java
index d1340736562e..3b9b45a1a17a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientListModelsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientListModelsTest.java
@@ -147,4 +147,77 @@ class LlmClientListModelsTest {
assertTrue(client.listModels().isEmpty());
}
+
+ @Test
+ void listsOpenAiModelsWhenConfiguredWithFullChatCompletionsUrl() throws
IOException {
+ // A user may configure the full chat endpoint; model discovery must
derive /v1/models from the same /v1 base
+ // instead of appending onto /v1/chat/completions.
+ String baseUrl = startServer("/v1/models", 200,
"{\"data\":[{\"id\":\"gpt-4o\"}]}", null, null);
+ LlmClient client = LlmClient.create()
+ .withApiType(LlmClient.ApiType.openai)
+ .withUrl(baseUrl + "/v1/chat/completions")
+ .withApiKey("sk-test");
+
+ assertEquals(List.of("gpt-4o"), client.listModels());
+ }
+
+ @Test
+ void assignsDefaultOpenAiModelWhenDetectionResolvesNoModel() throws
IOException {
+ String baseUrl = startServer("/v1/models", 200, "{\"data\":[]}", null,
null);
+ LlmClient client =
LlmClient.create().withApiType(LlmClient.ApiType.openai).withUrl(baseUrl);
+
+ assertTrue(client.detectEndpoint());
+ assertEquals(LlmClient.ApiType.openai, client.apiType());
+ assertEquals("gpt-4o-mini", client.model(), "OpenAI detection must
leave a usable default model, not null");
+ }
+
+ @Test
+ void fallsBackToDefaultOllamaModelWhenModelsCannotBeListed() throws
IOException {
+ // Root responds so the endpoint is detected, but /api/tags is
unavailable, so the installed models cannot be
+ // listed; detection must still leave a usable (non-null) model.
+ String baseUrl = startRootServer("Ollama is running");
+ LlmClient client = LlmClient.create().withUrl(baseUrl);
+
+ assertTrue(client.detectEndpoint());
+ assertEquals(LlmClient.ApiType.ollama, client.apiType());
+ assertEquals("llama3.2", client.model(), "Ollama detection must leave
a usable default model, not null");
+ }
+
+ @Test
+ void picksFirstInstalledOllamaModelWhenNoneConfiguredAndLlama32Absent()
throws IOException {
+ String baseUrl =
startOllamaServer("{\"models\":[{\"name\":\"qwen3\"},{\"name\":\"mistral\"}]}");
+ LlmClient client =
LlmClient.create().withApiType(LlmClient.ApiType.ollama).withUrl(baseUrl);
+
+ assertTrue(client.detectEndpoint());
+ assertEquals("qwen3", client.model(), "must pick the first installed
model when llama3.2 is not installed");
+ }
+
+ @Test
+ void prefersInstalledLlama32OllamaModelWhenNoneConfigured() throws
IOException {
+ String baseUrl =
startOllamaServer("{\"models\":[{\"name\":\"qwen3\"},{\"name\":\"llama3.2:latest\"}]}");
+ LlmClient client =
LlmClient.create().withApiType(LlmClient.ApiType.ollama).withUrl(baseUrl);
+
+ assertTrue(client.detectEndpoint());
+ assertEquals("llama3.2:latest", client.model(), "must prefer an
installed llama3.2 over the first entry");
+ }
+
+ /**
+ * Starts a server that answers the Ollama root probe ({@code "Ollama is
running"}) and serves the given
+ * {@code /api/tags} body, so endpoint detection and model resolution both
succeed against it.
+ */
+ private String startOllamaServer(String apiTagsBody) throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", exchange -> {
+ String path = exchange.getRequestURI().getPath();
+ String body = "/api/tags".equals(path) ? apiTagsBody : "Ollama is
running";
+ int status = "/".equals(path) || "/api/tags".equals(path) ? 200 :
404;
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(status, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ });
+ server.start();
+ return "http://127.0.0.1:" + server.getAddress().getPort();
+ }
}
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 6f2ac9b9aa7a..40c10e68adef 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
@@ -553,11 +553,31 @@ class AiPanel {
future.whenComplete(this::handleCliCompletion);
return;
}
+ if (result.modelListing()) {
+ listModelsAsync();
+ return;
+ }
if (result.text() != null && !result.text().isBlank()) {
conversation.add(new ConversationEntry(result.role(),
result.text()));
}
}
+ /**
+ * Fetches the available models off the TUI event thread. Model discovery
reaches a blocking HTTP call, so running
+ * it inline from key-event handling would freeze rendering and input if
the provider became slow or unreachable.
+ */
+ private void listModelsAsync() {
+ conversation.add(new ConversationEntry(AiRole.SYSTEM, "Fetching
available models..."));
+ Thread worker = new Thread(() -> {
+ List<String> models = slashCommandContext.availableModels();
+ conversation.add(new ConversationEntry(
+ AiRole.SYSTEM,
+
AiSlashCommandRegistry.formatModelListing(slashCommandContext.currentModel(),
models)));
+ }, "tui-ai-model-list");
+ worker.setDaemon(true);
+ worker.start();
+ }
+
private void handleCliCompletion(AiCliCommandExecutor.Result cliResult,
Throwable error) {
CompletableFuture<AiCliCommandExecutor.Result> future =
activeCliCommand;
synchronized (this) {
@@ -587,13 +607,15 @@ class AiPanel {
}
private void interruptBusyOperation() {
+ // A background CLI command and an LLM request can be in flight at the
same time, so cancel each one
+ // independently. Cancelling the CLI must not touch the LLM's thinking
state (that belongs to the agent
+ // thread, which clears it in its own finally block) and vice versa.
if (activeCliCommand != null) {
activeCliCommand = null;
slashCommandContext.cancelCli();
conversation.add(new ConversationEntry(AiRole.SYSTEM, "(command
cancelled)"));
- thinking.set(false);
- thinkingVerb = null;
- } else {
+ }
+ if (thinking.get()) {
stopAgentThread();
conversation.add(new ConversationEntry(AiRole.SYSTEM,
"(cancelled)"));
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
index 7631e1b60749..d755b6c689c3 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
@@ -287,16 +287,7 @@ final class AiSlashCommandRegistry {
}
return CommandResult.system("Switched model to " + arguments);
}
- List<String> models = context.availableModels();
- if (models.isEmpty()) {
- return CommandResult.system("Current model: " +
context.currentModel());
- }
- StringBuilder text = new StringBuilder("Current model:
").append(context.currentModel())
- .append("\nAvailable models:");
- for (String model : models) {
- text.append("\n- ").append(model);
- }
- return CommandResult.system(text.toString());
+ return CommandResult.listModels();
}
private static int firstWhitespace(String value) {
@@ -390,17 +381,37 @@ final class AiSlashCommandRegistry {
CommandResult execute(AiSlashCommandContext context, String arguments);
}
- record CommandResult(AiRole role, String text,
AiCliCommandExecutor.Request cliRequest) {
+ record CommandResult(AiRole role, String text,
AiCliCommandExecutor.Request cliRequest, boolean modelListing) {
static CommandResult system(String text) {
- return new CommandResult(AiRole.SYSTEM, text, null);
+ return new CommandResult(AiRole.SYSTEM, text, null, false);
}
static CommandResult error(String text) {
- return new CommandResult(AiRole.ERROR, text, null);
+ return new CommandResult(AiRole.ERROR, text, null, false);
}
static CommandResult async(AiCliCommandExecutor.Request request) {
- return new CommandResult(AiRole.SYSTEM, "Running " +
request.displayText(), request);
+ return new CommandResult(AiRole.SYSTEM, "Running " +
request.displayText(), request, false);
+ }
+
+ /**
+ * Signals the panel to fetch the available models off the event
thread, since model discovery reaches a
+ * blocking network call that must not freeze rendering and input. The
panel formats the result via
+ * {@link AiSlashCommandRegistry#formatModelListing(String, List)}.
+ */
+ static CommandResult listModels() {
+ return new CommandResult(AiRole.SYSTEM, null, null, true);
+ }
+ }
+
+ static String formatModelListing(String currentModel, List<String> models)
{
+ if (models.isEmpty()) {
+ return "Current model: " + currentModel;
+ }
+ StringBuilder text = new StringBuilder("Current model:
").append(currentModel).append("\nAvailable models:");
+ for (String model : models) {
+ text.append("\n- ").append(model);
}
+ return text.toString();
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
index cba61cc60c6a..eb00acd0b150 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
@@ -21,6 +21,7 @@ import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
import dev.tamboui.buffer.Buffer;
import dev.tamboui.layout.Rect;
@@ -36,6 +37,7 @@ import org.junit.jupiter.api.io.TempDir;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -176,9 +178,9 @@ class AiPanelTest {
type(panel, "/model");
panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
- assertTrue(panel.conversationForTesting().stream()
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.conversationForTesting().stream()
.anyMatch(entry -> entry.text().contains("Current model:
test-model")
- && entry.text().contains("model-a")));
+ && entry.text().contains("model-a"))));
type(panel, "/model model-b");
panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
@@ -214,10 +216,10 @@ class AiPanelTest {
type(panel, "/model");
panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
- assertTrue(panel.conversationForTesting().stream()
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.conversationForTesting().stream()
.anyMatch(entry -> entry.text().contains("Current model:
test-model")
&& entry.text().contains("model-a")
- && entry.text().contains("model-b")));
+ && entry.text().contains("model-b"))));
}
@Test
@@ -229,8 +231,31 @@ class AiPanelTest {
type(panel, "/model");
panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
- assertTrue(panel.conversationForTesting().stream()
- .anyMatch(entry -> entry.text().equals("Current model:
test-model")));
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertTrue(panel.conversationForTesting().stream()
+ .anyMatch(entry -> entry.text().equals("Current model:
test-model"))));
+ }
+
+ @Test
+ void modelListingRunsOffTheEventThread() {
+ AtomicReference<String> listThread = new AtomicReference<>();
+ AiPanel panel = new AiPanel();
+ panel.setClientForTesting(new LlmClient() {
+ @Override
+ public List<String> listModels() {
+ listThread.set(Thread.currentThread().getName());
+ return List.of("model-a");
+ }
+ });
+ panel.open();
+ String eventThread = Thread.currentThread().getName();
+
+ type(panel, "/model");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertNotNull(listThread.get()));
+ assertNotEquals(eventThread, listThread.get(),
+ "model discovery must not run on the TUI event thread, which
reaches a blocking HTTP call");
+ assertTrue(listThread.get().contains("model-list"));
}
@Test
@@ -416,6 +441,32 @@ class AiPanelTest {
await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertFalse(panel.isAgentThreadRunningForTesting()));
}
+ @Test
+ void escapeDuringOverlapCancelsBothCliAndLlm() {
+ AiPanel panel = new AiPanel();
+ FakeSlashContext context = new FakeSlashContext();
+ panel.setSlashCommandContextForTesting(context);
+ panel.setClientForTesting(new BlockingLlmClient());
+ panel.open();
+
+ // A background CLI command and an LLM request run concurrently: /send
does not lock the panel into
+ // thinking, so a question submitted afterwards starts the agent
thread while the CLI is still in flight.
+ type(panel, "/send direct:foo hello");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ type(panel, "what routes are running?");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ assertTrue(panel.isThinkingForTesting());
+ assertTrue(panel.isAgentThreadRunningForTesting());
+
+ // Esc while both are active must cancel the CLI *and* stop the LLM
agent, not silently orphan the agent
+ // thread while clearing the thinking indicator.
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE,
KeyModifiers.NONE));
+
+ assertTrue(context.cancelRequested, "the background CLI command must
be cancelled");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertFalse(panel.isAgentThreadRunningForTesting(),
+ "the LLM agent thread must also stop, leaving no orphan
mutating the conversation"));
+ }
+
@Test
void inputPromptUsesAccentChevron() {
AiPanel panel = new AiPanel();