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();

Reply via email to