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 ec354b868506 CAMEL-26424: camel-jbang - turn thinking off with local 
models in camel ask, explain and overview (#28742)
ec354b868506 is described below

commit ec354b86850646a600049a131102813dd9bc007d
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 31 18:12:33 2026 +0100

    CAMEL-26424: camel-jbang - turn thinking off with local models in camel 
ask, explain and overview (#28742)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/dsl/jbang/core/commands/LlmClient.java   |  58 ++++++++++++
 .../dsl/jbang/core/commands/FakeLlmServer.java     |  21 ++++-
 .../dsl/jbang/core/commands/LlmClientChatTest.java | 101 +++++++++++++++++++++
 3 files changed, 179 insertions(+), 1 deletion(-)

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 07aef3d6b984..11d280f58737 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
@@ -648,6 +648,7 @@ public class LlmClient {
         request.put("model", model);
         request.put("prompt", userPrompt);
         request.put("system", systemPrompt);
+        request.put("think", false);
         request.put("stream", stream);
         request.put("keep_alive", OLLAMA_KEEP_ALIVE);
         request.put("options", ollamaOptions());
@@ -670,6 +671,7 @@ public class LlmClient {
         request.put("model", model);
         request.put("messages", messages);
         request.put("temperature", temperature);
+        turnThinkingOff(request);
 
         String resolvedKey = resolveApiKey();
         String apiUrl = normalizeOpenAiUrl(url);
@@ -1016,6 +1018,7 @@ public class LlmClient {
         if (jsonTools != null) {
             request.put("tools", jsonTools);
         }
+        turnThinkingOff(request);
 
         String apiUrl = normalizeOpenAiUrl(url);
         String resolvedKey = resolveApiKey();
@@ -1024,6 +1027,57 @@ public class LlmClient {
         return parseOpenAiChatResponse(response);
     }
 
+    // ---- Thinking off with a local OpenAI-compatible server ----
+
+    // the fields for thinking a server refused, left out from then on
+    private volatile boolean reasoningEffortRefused;
+    private volatile boolean templateKwargsRefused;
+
+    /**
+     * Turns thinking off in a request to an OpenAI-compatible server on this 
machine, as {@code "think": false} does on
+     * Ollama's own API. Local servers (Ollama's /v1, llama.cpp server, vLLM, 
LM Studio) render the model's own chat
+     * template, and the Qwen 3.x templates think by default, which makes 
every answer many times slower. Ollama reads
+     * {@code reasoning_effort}, llama.cpp reads {@code chat_template_kwargs}, 
and vLLM reads both. Hosted endpoints get
+     * neither field, as they may refuse fields they do not know.
+     */
+    void turnThinkingOff(JsonObject request) {
+        if (apiType == ApiType.openai && isLocalEndpoint()) {
+            if (!reasoningEffortRefused) {
+                request.put("reasoning_effort", "none");
+            }
+            if (!templateKwargsRefused) {
+                request.put("chat_template_kwargs", new 
JsonObject(Map.of("enable_thinking", false)));
+            }
+        }
+    }
+
+    /**
+     * Sends a refused request again with one field for thinking less: first 
without {@code reasoning_effort}, as a
+     * server that checks its requests may not know the value none, then also 
without {@code chat_template_kwargs}. The
+     * field whose removal made the request succeed is left out from now on. 
When the request fails without both, its
+     * error is reported, as for any request.
+     */
+    private JsonObject sendWithFewerThinkingFields(String requestUrl, 
JsonObject body, String authKey) {
+        JsonObject request = new JsonObject(body);
+        boolean withoutEffort = request.remove("reasoning_effort") != null;
+        if (!withoutEffort) {
+            request.remove("chat_template_kwargs");
+        }
+        JsonObject response = sendRequest(requestUrl, request, authKey);
+        if (response != null) {
+            if (withoutEffort) {
+                reasoningEffortRefused = true;
+            } else {
+                templateKwargsRefused = true;
+            }
+            if (verbose) {
+                printer.println("[verbose] The server refused " + 
(withoutEffort ? "reasoning_effort" : "chat_template_kwargs")
+                                + ": it is left out from now on");
+            }
+        }
+        return response;
+    }
+
     private JsonObject ollamaOptions() {
         JsonObject options = new JsonObject();
         options.put("temperature", temperature);
@@ -1958,6 +2012,10 @@ public class LlmClient {
             if (response.statusCode() == 200) {
                 return (JsonObject) Jsoner.deserialize(response.body());
             }
+            if ((response.statusCode() == 400 || response.statusCode() == 422)
+                    && (body.containsKey("reasoning_effort") || 
body.containsKey("chat_template_kwargs"))) {
+                return sendWithFewerThinkingFields(requestUrl, body, authKey);
+            }
 
             handleErrorStatus(response.statusCode(), response.body());
             return null;
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
index c78115b7a910..0c8c8dd3186d 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
@@ -25,6 +25,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.CopyOnWriteArrayList;
 
 import com.sun.net.httpserver.Headers;
 import com.sun.net.httpserver.HttpExchange;
@@ -36,7 +37,8 @@ import org.apache.camel.util.json.Jsoner;
 /**
  * A local server that plays an LLM for the tests of the AI commands: Ollama 
(its models, and the answer of the model,
  * whole or streamed in two parts) and an OpenAI-compatible chat completion. A 
test can give the reply of a path
- * instead, such as a chat with tool calls or an error. It keeps the last 
question it answered, and its headers.
+ * instead, such as a chat with tool calls or an error. It keeps the questions 
it answered, and the headers of the last
+ * one.
  */
 final class FakeLlmServer implements AutoCloseable {
 
@@ -44,6 +46,7 @@ final class FakeLlmServer implements AutoCloseable {
     private final String answer;
     // the replies a test gave, by path: one for each request, and the last 
one for the rest
     private final Map<String, Deque<Reply>> replies = new 
ConcurrentHashMap<>();
+    private final List<JsonObject> requests = new CopyOnWriteArrayList<>();
     private volatile JsonObject lastRequest;
     private volatile Headers lastHeaders;
 
@@ -74,6 +77,14 @@ final class FakeLlmServer implements AutoCloseable {
         return this;
     }
 
+    /**
+     * Adds a reply with its own status after the replies given for the path, 
such as an answer after an error.
+     */
+    FakeLlmServer thenReply(String path, int status, String body) {
+        replies.computeIfAbsent(path, p -> new 
ConcurrentLinkedDeque<>()).add(new Reply(status, body));
+        return this;
+    }
+
     /**
      * The last question the server answered, as the client sent it.
      */
@@ -81,6 +92,13 @@ final class FakeLlmServer implements AutoCloseable {
         return lastRequest;
     }
 
+    /**
+     * The questions the server answered, in order, as the client sent them.
+     */
+    List<JsonObject> requests() {
+        return List.copyOf(requests);
+    }
+
     /**
      * The headers of the last question.
      */
@@ -136,6 +154,7 @@ final class FakeLlmServer implements AutoCloseable {
     private JsonObject read(HttpExchange exchange) throws IOException {
         String body = new String(exchange.getRequestBody().readAllBytes(), 
StandardCharsets.UTF_8);
         JsonObject request = Jsoner.deserialize(body, new JsonObject());
+        requests.add(request);
         lastRequest = request;
         lastHeaders = exchange.getRequestHeaders();
         return request;
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientChatTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientChatTest.java
index 291bd6b5f9bb..24b6efcdd1c2 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientChatTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/LlmClientChatTest.java
@@ -167,6 +167,107 @@ class LlmClientChatTest {
         assertThat(response.stopReason()).isEqualTo("stop");
     }
 
+    @Test
+    void openAiChatWithALocalServerTurnsThinkingOff() {
+        server.reply("/v1/chat/completions", 200, 
"{\"choices\":[{\"message\":{\"content\":\"Done\"}}]}");
+
+        client(ApiType.openai).chatWithTools("You help", 
List.of(Message.user("Hi")), TOOLS);
+
+        // Ollama's /v1 reads reasoning_effort, llama.cpp reads 
chat_template_kwargs, vLLM reads both
+        JsonObject request = server.lastRequest();
+        assertThat(request.getString("reasoning_effort")).isEqualTo("none");
+        assertThat(((JsonObject) 
request.get("chat_template_kwargs")).getBoolean("enable_thinking")).isFalse();
+    }
+
+    @Test
+    void anExplanationWithALocalServerTurnsThinkingOff() {
+        server.reply("/v1/chat/completions", 200, 
"{\"choices\":[{\"message\":{\"content\":\"Done\"}}]}");
+
+        String answer = client(ApiType.openai).generate("You explain", "The 
route");
+
+        assertThat(answer).isEqualTo("Done");
+        
assertThat(server.lastRequest().getString("reasoning_effort")).isEqualTo("none");
+    }
+
+    @Test
+    void anExplanationWithOllamaTurnsThinkingOff() {
+        server.reply("/api/generate", 200, 
"{\"response\":\"Done\",\"done\":true}");
+
+        String answer = client(ApiType.ollama).generate("You explain", "The 
route");
+
+        // as the chat does: the Qwen 3.x models think by default, which makes 
an explanation many times slower
+        assertThat(answer).isEqualTo("Done");
+        assertThat(server.lastRequest().getBoolean("think")).isFalse();
+    }
+
+    @Test
+    void aHostedServerGetsNoFieldsForThinking() {
+        JsonObject request = new JsonObject();
+
+        
LlmClient.create().withApiType(ApiType.openai).withUrl("https://api.openai.com/v1";).turnThinkingOff(request);
+        
LlmClient.create().withApiType(ApiType.openai).withUrl("https://myresource.openai.azure.com";)
+                .turnThinkingOff(request);
+        
LlmClient.create().withApiType(ApiType.watsonx).withUrl("https://us-south.ml.cloud.ibm.com";)
+                .turnThinkingOff(request);
+
+        assertThat(request).isEmpty();
+    }
+
+    @Test
+    void aServerThatRefusesReasoningEffortGetsTheQuestionAgainWithoutIt() {
+        server.reply("/v1/chat/completions", 400,
+                "{\"error\":{\"message\":\"reasoning_effort: Input should be 
'low', 'medium' or 'high'\"}}")
+                .thenReply("/v1/chat/completions", 200, 
"{\"choices\":[{\"message\":{\"content\":\"Done\"}}]}");
+        LlmClient client = client(ApiType.openai);
+
+        ChatResponse response = client.chatWithTools("You help", 
List.of(Message.user("Hi")), TOOLS);
+        client.chatWithTools("You help", List.of(Message.user("Hi again")), 
TOOLS);
+
+        assertThat(response.text()).isEqualTo("Done");
+        assertThat(printer.getStderr()).isEmpty();
+        List<JsonObject> requests = server.requests();
+        assertThat(requests).hasSize(3);
+        assertThat(requests.get(0)).containsKeys("reasoning_effort", 
"chat_template_kwargs");
+        // the same question again without it, and the next question leaves it 
out from the start
+        
assertThat(requests.get(1)).doesNotContainKey("reasoning_effort").containsKey("chat_template_kwargs");
+        
assertThat(requests.get(2)).doesNotContainKey("reasoning_effort").containsKey("chat_template_kwargs");
+    }
+
+    @Test
+    void aServerThatRefusesBothFieldsForThinkingGetsTheQuestionWithoutThem() {
+        server.reply("/v1/chat/completions", 422, "{\"detail\":\"Extra inputs 
are not permitted\"}",
+                "{\"detail\":\"Extra inputs are not permitted\"}")
+                .thenReply("/v1/chat/completions", 200, 
"{\"choices\":[{\"message\":{\"content\":\"Done\"}}]}");
+        LlmClient client = client(ApiType.openai);
+
+        ChatResponse response = client.chatWithTools("You help", 
List.of(Message.user("Hi")), TOOLS);
+        client.chatWithTools("You help", List.of(Message.user("Hi again")), 
TOOLS);
+
+        assertThat(response.text()).isEqualTo("Done");
+        assertThat(printer.getStderr()).isEmpty();
+        List<JsonObject> requests = server.requests();
+        assertThat(requests).hasSize(4);
+        
assertThat(requests.get(1)).doesNotContainKey("reasoning_effort").containsKey("chat_template_kwargs");
+        assertThat(requests.get(2)).doesNotContainKeys("reasoning_effort", 
"chat_template_kwargs");
+        assertThat(requests.get(3)).doesNotContainKeys("reasoning_effort", 
"chat_template_kwargs");
+    }
+
+    @Test
+    void aRefusalForAnotherReasonIsReportedOnce() {
+        server.reply("/v1/chat/completions", 400, 
"{\"error\":{\"message\":\"The prompt is too long\"}}");
+        LlmClient client = client(ApiType.openai);
+
+        ChatResponse response = client.chatWithTools("You help", 
List.of(Message.user("Hi")), TOOLS);
+
+        assertThat(response.stopReason()).isEqualTo("error");
+        assertThat(printer.getStderr()).containsOnlyOnce("LLM returned status: 
400").contains("The prompt is too long");
+        assertThat(server.requests()).hasSize(3);
+
+        // the fields were not the cause, so the next question has them again
+        client.chatWithTools("You help", List.of(Message.user("Hi again")), 
TOOLS);
+        assertThat(server.requests().get(3)).containsKeys("reasoning_effort", 
"chat_template_kwargs");
+    }
+
     @Test
     void anthropicChatWithToolUse() {
         server.reply("/v1/messages", 200, """

Reply via email to