You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by mc...@apache.org on 2026/10/10 14:15:01 UTC
(hop) branch main updated: Fixes #8835 : Add a Thinking option to the AI provider to switch reas… (#8840)
This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 81b92a4348 Fixes #8835 : Add a Thinking option to the AI provider to switch reas… (#8840)
81b92a4348 is described below
commit 81b92a43483bfd4bc20e8dbe623b4f386b8acd55
Author: Bart Maertens <ba...@know.bi>
AuthorDate: Sat Oct 10 16:14:56 2026 +0200
Fixes #8835 : Add a Thinking option to the AI provider to switch reas… (#8840)
* Fixes #8835 : Add a Thinking option to the AI provider to switch reasoning off for Ollama models
* Issue #8835 : Address review: Default thinking wording, schema request test, store thinking codes
---
docker/integration-tests/integration-tests-ai.yaml | 11 +-
.../ROOT/pages/metadata-types/ai-provider.adoc | 15 +-
.../pipeline/transforms/languagemodelchat.adoc | 7 +
integration-tests/ai/0005-thinking.hpl | 487 +++++++++++++++++++++
.../ai/0006-structured-extract-thinking-off.hpl | 246 +++++++++++
integration-tests/ai/README.md | 2 +
integration-tests/ai/main-0005-thinking.hwf | 91 ++++
.../main-0006-structured-extract-thinking-off.hwf | 91 ++++
.../metadata/ai-provider/ollama-thinking-off.json | 13 +
.../org/apache/hop/ai/engine/AiChatFactory.java | 5 +
.../apache/hop/ai/engine/AiChatModelFactory.java | 6 +
.../apache/hop/ai/engine/AiProviderSettings.java | 13 +
.../org/apache/hop/ai/metadata/AiProvider.java | 51 +++
.../org/apache/hop/ai/metadata/AiThinking.java | 111 +++++
.../ai/metadata/messages/messages_en_US.properties | 6 +
.../apache/hop/ai/engine/AiChatFactoryTest.java | 66 +++
.../hop/ai/engine/AiChatModelFactoryTest.java | 158 +++++++
.../hop/ai/engine/AiProviderSettingsTest.java | 31 ++
.../ai/metadata/AiProviderSerializationTest.java | 35 ++
.../org/apache/hop/ai/metadata/AiProviderTest.java | 56 +++
.../org/apache/hop/ai/metadata/AiThinkingTest.java | 85 ++++
.../StructuredExtractThinkingTest.java | 131 ++++++
.../languagemodelchat/LanguageModelChatMeta.java | 5 +
.../internals/LanguageModelFacade.java | 5 +
.../languagemodelchat/internals/OllamaThink.java | 73 +++
.../internals/ui/models/OllamaComposite.java | 33 ++
.../messages/messages_en_US.properties | 5 +
.../LanguageModelChatMetaThinkTest.java | 92 ++++
.../internals/LanguageModelFacadeChatTest.java | 35 ++
.../ui/models/OllamaCompositeThinkTest.java | 43 ++
30 files changed, 2004 insertions(+), 4 deletions(-)
diff --git a/docker/integration-tests/integration-tests-ai.yaml b/docker/integration-tests/integration-tests-ai.yaml
index 8da4e03c59..41a9c32b45 100644
--- a/docker/integration-tests/integration-tests-ai.yaml
+++ b/docker/integration-tests/integration-tests-ai.yaml
@@ -47,14 +47,19 @@ services:
# token and timed the test out; four threads answered in about a second.
printf 'FROM qwen2.5:0.5b\nPARAMETER num_thread 4\n' > /tmp/Modelfile
ollama create qwen2.5-hop-it -f /tmp/Modelfile
+ # qwen3 reasons before it answers unless told not to, which the Thinking test needs.
+ ollama pull qwen3:0.6b
+ printf 'FROM qwen3:0.6b\nPARAMETER num_thread 4\n' > /tmp/Modelfile-qwen3
+ ollama create qwen3-hop-it -f /tmp/Modelfile-qwen3
wait
healthcheck:
# nomic-embed-text is ~274MB and returns 768 dimensions, which the Embed text test asserts
# on. qwen2.5:0.5b is ~400MB and is the chat model Structured extract uses: Ollama
# constrains generation to the JSON schema, so a small model is enough to return the right
- # shape. It is served as qwen2.5-hop-it, pinned to four threads. Extend this condition when
- # another model is added above.
- test: ["CMD-SHELL", "ollama list | grep -q nomic-embed-text && ollama list | grep -q qwen2.5-hop-it"]
+ # shape. It is served as qwen2.5-hop-it, pinned to four threads. qwen3:0.6b is ~520MB, the
+ # smallest thinking model, served as qwen3-hop-it for the Thinking test. Extend this
+ # condition when another model is added above.
+ test: ["CMD-SHELL", "ollama list | grep -q nomic-embed-text && ollama list | grep -q qwen2.5-hop-it && ollama list | grep -q qwen3-hop-it"]
interval: 10s
timeout: 10s
retries: 60
diff --git a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc
index 63382c8b50..a94b19b538 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc
@@ -96,7 +96,7 @@ A GitHub Copilot-style OAuth provider is an extension point; it is not bundled.
== Options
-The editor shows *Name* and *Provider* at the top, and the other options on three tabs: *Connection* (base URL, API key, timeout), *Model* (model name, temperature, context size, max output tokens, structured answers) and *Models per role*.
+The editor shows *Name* and *Provider* at the top, and the other options on three tabs: *Connection* (base URL, API key, timeout), *Model* (model name, temperature, context size, max output tokens, thinking, structured answers) and *Models per role*.
Saving checks that a provider type is selected and that the numbers are valid; values that use a variable are checked when the provider is used.
|===
@@ -136,6 +136,19 @@ A larger window uses more memory on the Ollama server.
Leave it empty for the provider's default, which is 4096 for Anthropic (Anthropic requires a value).
A value that is too low cuts answers and proposals short.
+|Thinking
+|Whether a thinking model (qwen3, deepseek-r1, gpt-oss, …) reasons before it answers.
+
+* *Default*: leave it to the model. Most thinking models then reason. Depending on the model and the Ollama version, the answer can then include the reasoning between `<think>` tags. This is the value for providers created before the option existed, and it keeps their output as it was.
+* *Off*: answer right away (`think: false`).
+* *On*: always reason first (`think: true`).
+
+For transforms that call the model once per row, the reasoning usually takes most of the run time: a short Extract graph call that writes thousands of reasoning tokens with the default writes about a hundred with *Off*.
+Adding `/no_think` to a prompt is not a reliable alternative: not every model honours it.
+With *Off* or *On* the reasoning is kept out of the answer.
+The value can be a variable, such as `+'${AI_THINKING}'+`, that resolves to `Default`, `Off` or `On`.
+Applies to Ollama only for now; other provider types ignore it.
+
|Structured answers
|Off by default.
When checked, the AI Assistant asks the provider to hold the model to a JSON schema: the answer text plus a list of proposals with a known type, a risk level and their parameters.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/languagemodelchat.adoc b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/languagemodelchat.adoc
index eae2a61172..dd32da2381 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/languagemodelchat.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/languagemodelchat.adoc
@@ -97,6 +97,9 @@ When *AI Provider* is set, the transform starts from its own settings and then c
| Timeout
| The provider's *Timeout (seconds)* if it resolves to a whole number, otherwise the inline timeout.
+
+| Thinking (Ollama)
+| The provider's *Thinking* if it is *Off* or *On*, otherwise the inline *Thinking*.
|===
Variables in provider fields are resolved first, so apart from *Temperature*, a field holding a variable that resolves to an empty value counts as empty. Empty provider fields fall back to the inline values, not to the defaults listed for each provider type on the xref:metadata-types/ai-provider.adoc[AI Provider] page.
@@ -136,6 +139,7 @@ Below is a table detailing parameters specific to individual language model APIs
|Wait for Model | ❌ | ❌ | ✅ | ❌ | ❌
|Use Proxy | ✅ | ❌ | ❌ | ❌ | ❌
|Context Window Size | ❌ | ❌ | ❌ | ❌ | ✅
+|Thinking | ❌ | ❌ | ❌ | ❌ | ✅
|===
@@ -155,6 +159,9 @@ Below is a table detailing parameters specific to individual language model APIs
|Context Window Size
|Specifies how much input tokens the model can consider at once.
+|Thinking
+|Whether a thinking model (qwen3, deepseek-r1, gpt-oss, …) reasons before it answers. *Default* leaves it to the model, *Off* answers right away, which is much faster when the model is called once per row, and *On* always reasons. With *Off* or *On* the reasoning is kept out of the output; with *Default* the model and the Ollama version decide, and the output can include the reasoning between `<think>` tags.
+
|Timeout
|Determines how long to wait for a response before giving up.
diff --git a/integration-tests/ai/0005-thinking.hpl b/integration-tests/ai/0005-thinking.hpl
new file mode 100644
index 0000000000..4eabe0c237
--- /dev/null
+++ b/integration-tests/ai/0005-thinking.hpl
@@ -0,0 +1,487 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>0005-thinking</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Ask a thinking model the same question with Language Model Chat's Thinking option
+ Off and On, and check that the option reaches Ollama.</description>
+ <extended_description>qwen3 reasons before it answers unless told not to, and Ollama counts
+ those reasoning tokens as output. With Thinking Off the answer to a one word question is a
+ handful of tokens; with Thinking On the same question at temperature 0 costs many more. If
+ the option did not reach Ollama both rows would show the same count. The answer itself must
+ not carry the reasoning.</extended_description>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters/>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/10/10 09:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/10 09:00:00.000</modified_date>
+ </info>
+ <notepads/>
+ <order>
+ <hop>
+ <from>prompt</from>
+ <to>thinking off</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>thinking off</from>
+ <to>thinking on</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>thinking on</from>
+ <to>check no error off</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check no error off</from>
+ <to>check no error on</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check no error on</from>
+ <to>check answer</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check answer</from>
+ <to>check no reasoning in answer</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check no reasoning in answer</from>
+ <to>check off is short</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check off is short</from>
+ <to>check on reasons</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check on reasons</from>
+ <to>answer</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check no error off</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check no error on</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check answer</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check no reasoning in answer</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check off is short</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check on reasons</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>prompt</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <name>prompt</name>
+ <type>String</type>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <data>
+ <line>
+ <item>Reply with one word: what colour is a clear sky at noon?</item>
+ </line>
+ </data>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>thinking off</name>
+ <type>LanguageModelChat</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <inputField>prompt</inputField>
+ <inputChatJson>false</inputChatJson>
+ <outputChatJson>false</outputChatJson>
+ <mock>false</mock>
+ <outputFieldNamePrefix>off_</outputFieldNamePrefix>
+ <modelType>OLLAMA</modelType>
+ <parallelism>1</parallelism>
+ <openAiUseProxy>false</openAiUseProxy>
+ <openAiLogRequests>false</openAiLogRequests>
+ <openAiLogResponses>false</openAiLogResponses>
+ <huggingFaceReturnFullText>false</huggingFaceReturnFullText>
+ <huggingFaceWaitForModel>false</huggingFaceWaitForModel>
+ <mistralSafePrompt>false</mistralSafePrompt>
+ <mistralLogRequests>false</mistralLogRequests>
+ <mistralLogResponses>false</mistralLogResponses>
+ <ollamaImageEndpoint>${OLLAMA_BASE_URL}</ollamaImageEndpoint>
+ <ollamaModelName>qwen3-hop-it</ollamaModelName>
+ <ollamaTemperature>0.0</ollamaTemperature>
+ <ollamaNumPredict>512</ollamaNumPredict>
+ <ollamaThink>OFF</ollamaThink>
+ <ollamaTimeout>300</ollamaTimeout>
+ <ollamaMaxRetries>3</ollamaMaxRetries>
+ <anthropicLogRequests>false</anthropicLogRequests>
+ <anthropicLogResponses>false</anthropicLogResponses>
+ <attributes/>
+ <GUI>
+ <xloc>256</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>thinking on</name>
+ <type>LanguageModelChat</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <inputField>prompt</inputField>
+ <inputChatJson>false</inputChatJson>
+ <outputChatJson>false</outputChatJson>
+ <mock>false</mock>
+ <outputFieldNamePrefix>on_</outputFieldNamePrefix>
+ <modelType>OLLAMA</modelType>
+ <parallelism>1</parallelism>
+ <openAiUseProxy>false</openAiUseProxy>
+ <openAiLogRequests>false</openAiLogRequests>
+ <openAiLogResponses>false</openAiLogResponses>
+ <huggingFaceReturnFullText>false</huggingFaceReturnFullText>
+ <huggingFaceWaitForModel>false</huggingFaceWaitForModel>
+ <mistralSafePrompt>false</mistralSafePrompt>
+ <mistralLogRequests>false</mistralLogRequests>
+ <mistralLogResponses>false</mistralLogResponses>
+ <ollamaImageEndpoint>${OLLAMA_BASE_URL}</ollamaImageEndpoint>
+ <ollamaModelName>qwen3-hop-it</ollamaModelName>
+ <ollamaTemperature>0.0</ollamaTemperature>
+ <ollamaNumPredict>512</ollamaNumPredict>
+ <ollamaThink>ON</ollamaThink>
+ <ollamaTimeout>300</ollamaTimeout>
+ <ollamaMaxRetries>3</ollamaMaxRetries>
+ <anthropicLogRequests>false</anthropicLogRequests>
+ <anthropicLogResponses>false</anthropicLogResponses>
+ <attributes/>
+ <GUI>
+ <xloc>416</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check no error off</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>check no error on</send_true_to>
+ <send_false_to>Abort</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>off_finish_reason</leftvalue>
+ <function><></function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>ERROR</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>576</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check no error on</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>check answer</send_true_to>
+ <send_false_to>Abort</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>on_finish_reason</leftvalue>
+ <function><></function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>ERROR</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>736</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check answer</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>check no reasoning in answer</send_true_to>
+ <send_false_to>Abort</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>off_output</leftvalue>
+ <function>IS NOT NULL</function>
+ <rightvalue/>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>896</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check no reasoning in answer</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>check off is short</send_true_to>
+ <send_false_to>Abort</send_false_to>
+ <compare>
+ <condition>
+ <negated>Y</negated>
+ <leftvalue>off_output</leftvalue>
+ <function>CONTAINS</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text><think></text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>1056</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check off is short</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>check on reasons</send_true_to>
+ <send_false_to>Abort</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>off_output_token_count</leftvalue>
+ <function><</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>64</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>1216</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check on reasons</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>answer</send_true_to>
+ <send_false_to>Abort</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>on_output_token_count</leftvalue>
+ <function>></function>
+ <rightvalue>off_output_token_count</rightvalue>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>1376</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>answer</name>
+ <type>WriteToLog</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <loglevel>log_level_basic</loglevel>
+ <displayHeader>Y</displayHeader>
+ <limitRows>Y</limitRows>
+ <limitRowsNumber>1</limitRowsNumber>
+ <logmessage>Thinking Off and On both reached Ollama</logmessage>
+ <fields>
+ <field>
+ <name>off_output_token_count</name>
+ </field>
+ <field>
+ <name>on_output_token_count</name>
+ </field>
+ <field>
+ <name>off_output</name>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>1536</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Abort</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <row_threshold>0</row_threshold>
+ <message>Thinking Off or On did not reach Ollama. The row logged above shows the output and token counts with each.</message>
+ <always_log_rows>Y</always_log_rows>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <attributes/>
+ <GUI>
+ <xloc>896</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling/>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/ai/0006-structured-extract-thinking-off.hpl b/integration-tests/ai/0006-structured-extract-thinking-off.hpl
new file mode 100644
index 0000000000..c91d4e089e
--- /dev/null
+++ b/integration-tests/ai/0006-structured-extract-thinking-off.hpl
@@ -0,0 +1,246 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<pipeline>
+ <info>
+ <name>0006-structured-extract-thinking-off</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Structured extract with the thinking model qwen3, through an AI provider whose
+ Thinking option is Off. This checks that a thinking model held to a JSON schema still returns
+ both required fields typed and non null with that setting. It does not show that think was
+ sent: Structured extract reports no token counts, and with think on the JSON still comes back
+ in the answer. StructuredExtractThinkingTest checks the flag on this request.</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>0</pipeline_status>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/10/10 09:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/10 09:00:00.000</modified_date>
+ <key_for_session_key/>
+ <is_key_private>N</is_key_private>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop> <from>tickets</from> <to>Structured extract</to> <enabled>Y</enabled> </hop>
+ <hop> <from>Structured extract</from> <to>check severity</to> <enabled>Y</enabled> </hop>
+ <hop> <from>check severity</from> <to>check impact</to> <enabled>Y</enabled> </hop>
+ <hop> <from>check severity</from> <to>Abort</to> <enabled>Y</enabled> </hop>
+ <hop> <from>check impact</from> <to>OUTPUT</to> <enabled>Y</enabled> </hop>
+ <hop> <from>check impact</from> <to>Abort</to> <enabled>Y</enabled> </hop>
+ </order>
+ <transform>
+ <name>tickets</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <data>
+ <line>
+ <item>Production database is unreachable since 03:00, the whole order intake is down. Estimated loss so far 4500 euros.</item>
+ </line>
+ <line>
+ <item>The export button on the reporting screen is slightly misaligned in dark mode. Cosmetic only.</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>ticket_text</name>
+ <type>String</type>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Structured extract</name>
+ <type>StructuredExtract</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <ai_provider>ollama-thinking-off</ai_provider>
+ <model_name/>
+ <input_field>ticket_text</input_field>
+ <instructions/>
+ <fields>
+ <field>
+ <name>severity</name>
+ <type>String</type>
+ <description>How urgent this ticket is</description>
+ <required>Y</required>
+ <allowed_values>low,medium,high</allowed_values>
+ </field>
+ <field>
+ <name>customer_impact</name>
+ <type>Boolean</type>
+ <description>Whether customers are affected right now</description>
+ <required>Y</required>
+ <allowed_values></allowed_values>
+ </field>
+ <field>
+ <name>estimated_loss</name>
+ <type>Number</type>
+ <description>Money lost so far in euros, if the ticket says</description>
+ <required>N</required>
+ <allowed_values></allowed_values>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>288</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check severity</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>check impact</send_true_to>
+ <send_false_to>Abort</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>severity</leftvalue>
+ <function>IS NOT NULL</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text></text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>480</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check impact</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>OUTPUT</send_true_to>
+ <send_false_to>Abort</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>customer_impact</leftvalue>
+ <function>IS NOT NULL</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text></text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>656</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>OUTPUT</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>832</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Abort</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <row_threshold>0</row_threshold>
+ <message>Structured extract with Thinking Off did not return both required fields. The row logged above shows what came back.</message>
+ <always_log_rows>Y</always_log_rows>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <attributes/>
+ <GUI>
+ <xloc>608</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/ai/README.md b/integration-tests/ai/README.md
index 7f0e465ce9..eb6a0950e9 100644
--- a/integration-tests/ai/README.md
+++ b/integration-tests/ai/README.md
@@ -43,3 +43,5 @@ standard suite. See `disabled.txt`.
|---|---|
| `0001-embed-text` | The Embed text transform against `nomic-embed-text`: the vector width, the model name on the row, and that an embedding comes back. The vector values themselves vary per call, so they are not compared. |
| `0004-embed-blank-text` | Embed text with empty and whitespace-only values in the same batch as real text: those rows pass through without an embedding, the others get one, and no row is lost. |
+| `0005-thinking` | Language Model Chat against the thinking model `qwen3:0.6b` with Thinking Off and On. Ollama counts reasoning tokens as output, so Off must answer in under 64 output tokens and On must use more than Off; if `think` did not reach Ollama both counts would be the same at temperature 0. The answer with Off must not contain `<think>`. |
+| `0006-structured-extract-thinking-off` | Structured extract with `qwen3:0.6b` through the `ollama-thinking-off` AI provider: a thinking model held to a JSON schema with Thinking Off must still return both required fields. It cannot show that `think` was sent, since Structured extract reports no token counts and the JSON comes back either way; `StructuredExtractThinkingTest` checks the flag on this request. |
diff --git a/integration-tests/ai/main-0005-thinking.hwf b/integration-tests/ai/main-0005-thinking.hwf
new file mode 100644
index 0000000000..bfcdc64d7b
--- /dev/null
+++ b/integration-tests/ai/main-0005-thinking.hwf
@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>main-0005-thinking</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>A thinking model with Language Model Chat Thinking Off and On.</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/10/10 09:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/10 09:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>64</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Thinking off and on</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+ <filename>${PROJECT_HOME}/0005-thinking.hpl</filename>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Thinking off and on</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git a/integration-tests/ai/main-0006-structured-extract-thinking-off.hwf b/integration-tests/ai/main-0006-structured-extract-thinking-off.hwf
new file mode 100644
index 0000000000..558713df08
--- /dev/null
+++ b/integration-tests/ai/main-0006-structured-extract-thinking-off.hwf
@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+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.
+
+-->
+<workflow>
+ <name>main-0006-structured-extract-thinking-off</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Structured extract with a thinking model and Thinking Off.</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/10/10 09:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/10/10 09:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>64</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Extract from tickets</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+ <filename>${PROJECT_HOME}/0006-structured-extract-thinking-off.hpl</filename>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>80</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Extract from tickets</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git a/integration-tests/ai/metadata/ai-provider/ollama-thinking-off.json b/integration-tests/ai/metadata/ai-provider/ollama-thinking-off.json
new file mode 100644
index 0000000000..ab56f9457d
--- /dev/null
+++ b/integration-tests/ai/metadata/ai-provider/ollama-thinking-off.json
@@ -0,0 +1,13 @@
+{
+ "modelName": "qwen3-hop-it",
+ "baseUrl": "http://ollama:11434",
+ "apiKey": "Encrypted ",
+ "provider": {
+ "ollama": {}
+ },
+ "name": "ollama-thinking-off",
+ "temperature": "0",
+ "timeoutSeconds": "300",
+ "maxOutputTokens": "512",
+ "thinking": "OFF"
+}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatFactory.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatFactory.java
index 625cadabd0..2124244f6d 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatFactory.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatFactory.java
@@ -42,6 +42,7 @@ import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.metadata.api.IHopMetadataProvider;
import org.apache.hop.pipeline.transforms.languagemodelchat.LanguageModelChatMeta;
import org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelFacade;
+import org.apache.hop.pipeline.transforms.languagemodelchat.internals.OllamaThink;
/** Maps {@link AiProvider} metadata onto Language Model Chat and runs a completion. */
public final class AiChatFactory {
@@ -135,6 +136,7 @@ public final class AiChatFactory {
Integer timeout = parseTimeout(resolve(variables, provider.getTimeoutSeconds()));
Integer contextSize = AiProviderSettings.contextSize(provider, variables);
Integer maxOutputTokens = AiProviderSettings.maxOutputTokens(provider, variables);
+ Boolean think = AiProviderSettings.think(provider, variables);
boolean applyTemperature = useProviderDefaults || hasTemperature;
String hopType = backend.getHopModelType();
@@ -176,6 +178,9 @@ public final class AiChatFactory {
if (maxOutputTokens != null) {
meta.setOllamaNumPredict(maxOutputTokens);
}
+ if (think != null) {
+ meta.setOllamaThink(OllamaThink.of(think));
+ }
} else if ("MISTRAL".equals(hopType)) {
if (!Utils.isEmpty(baseUrl)) {
meta.setMistralBaseUrl(baseUrl);
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatModelFactory.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatModelFactory.java
index ba6ead8073..390cc2b4e7 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatModelFactory.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatModelFactory.java
@@ -134,6 +134,12 @@ public final class AiChatModelFactory {
if (settings.maxOutputTokens() != null) {
builder.numPredict(settings.maxOutputTokens());
}
+ if (settings.think() != null) {
+ builder.think(settings.think());
+ }
+ // Only drops the separate thinking field. With think unset, the model and the Ollama version
+ // decide whether the answer carries <think> tags, as before this option existed.
+ builder.returnThinking(false);
return builder.build();
}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderSettings.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderSettings.java
index bd22d8008f..ac1a82d795 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderSettings.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderSettings.java
@@ -18,6 +18,7 @@ package org.apache.hop.ai.engine;
import java.time.Duration;
import org.apache.hop.ai.metadata.AiProvider;
+import org.apache.hop.ai.metadata.AiThinking;
import org.apache.hop.ai.provider.IAiProvider;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.util.Utils;
@@ -36,6 +37,7 @@ import org.apache.hop.i18n.BaseMessages;
* @param temperature the sampling temperature, or null when not set
* @param contextSize the context window in tokens, or null when unknown
* @param maxOutputTokens the answer length limit in tokens, or null for the provider's default
+ * @param think whether a thinking model reasons before it answers, or null to leave it to the model
* @param backend the provider type, which says how to talk to the endpoint
*/
public record AiProviderSettings(
@@ -45,6 +47,7 @@ public record AiProviderSettings(
Double temperature,
Integer contextSize,
Integer maxOutputTokens,
+ Boolean think,
IAiProvider backend) {
/**
@@ -82,6 +85,7 @@ public record AiProviderSettings(
parseDouble(variables.resolve(provider.getTemperature())),
contextSize(provider, variables),
maxOutputTokens(provider, variables),
+ think(provider, variables),
backend);
}
@@ -129,6 +133,15 @@ public record AiProviderSettings(
: null;
}
+ /**
+ * The Thinking setting as the model's {@code think} option: false for Off, true for On, and null
+ * for Default or a value that is not a setting, so the model decides as it did before.
+ */
+ public static Boolean think(AiProvider provider, IVariables variables) {
+ AiThinking thinking = AiThinking.lookup(resolve(variables, provider.getThinking()));
+ return thinking == null ? null : thinking.think();
+ }
+
static Integer parsePositiveInt(String text) {
if (Utils.isEmpty(text)) {
return null;
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java
index e2e8ab1a6c..c404646172 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java
@@ -67,6 +67,7 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
public static final String WIDGET_TEMPERATURE = "0500-temperature";
public static final String WIDGET_CONTEXT_SIZE = "0510-context-size";
public static final String WIDGET_MAX_OUTPUT_TOKENS = "0520-max-output-tokens";
+ public static final String WIDGET_THINKING = "0525-thinking";
public static final String WIDGET_STRUCTURED_ANSWERS = "0530-structured-answers";
public static final String WIDGET_MODELS = "0600-models";
@@ -171,6 +172,29 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
toolTip = "i18n::AiProvider.MaxOutputTokens.Tooltip")
private String maxOutputTokens = "";
+ /**
+ * Whether the model reasons before it answers: the code of one of the {@link AiThinking}
+ * settings, or a variable that resolves to one. Empty leaves it to the model. Only Ollama applies
+ * it so far.
+ *
+ * <p>The editor shows the label and {@link #setThinking} stores the code, so a provider saved in
+ * one language loads the same in another.
+ */
+ @HopMetadataProperty
+ @GuiWidgetElement(
+ id = WIDGET_THINKING,
+ order = "0525",
+ type = GuiElementType.COMBO,
+ comboValuesMethod = "getThinkingChoices",
+ getterMethod = "getThinkingLabel",
+ parentId = GUI_WIDGETS_PARENT_ID,
+ groupType = GuiWidgetGroupType.TABS,
+ group = "i18n::AiProviderEditor.Group.Model",
+ groupOrder = "20",
+ label = "i18n::AiProvider.Thinking.Label",
+ toolTip = "i18n::AiProvider.Thinking.Tooltip")
+ private String thinking = "";
+
/**
* Have the AI Assistant's answers follow a JSON schema, where the provider type can hold a model
* to one (Ollama, OpenAI, Anthropic, Mistral). Off by default until it is checked with the
@@ -225,6 +249,7 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
this.temperature = other.temperature;
this.contextSize = other.contextSize;
this.maxOutputTokens = other.maxOutputTokens;
+ this.thinking = other.thinking;
this.structuredAnswers = other.structuredAnswers;
for (AiProviderModel model : other.models) {
this.models.add(new AiProviderModel(model));
@@ -329,6 +354,14 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
checkPositiveInt(problems, "AiProvider.Timeout.Label", timeoutSeconds);
checkPositiveInt(problems, "AiProvider.ContextSize.Label", contextSize);
checkPositiveInt(problems, "AiProvider.MaxOutputTokens.Label", maxOutputTokens);
+ if (!isVariable(thinking) && AiThinking.lookup(thinking) == null) {
+ problems.add(
+ BaseMessages.getString(
+ PKG,
+ "AiProvider.Validate.Thinking",
+ thinking,
+ String.join(", ", AiThinking.descriptions())));
+ }
return problems;
}
@@ -359,6 +392,24 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
return AiChatFactory.healthCheck(this, variables);
}
+ /**
+ * Stores the code of a recognised setting, given as a code or a label. A variable, or a value
+ * that is not a setting, is kept as it is so it can be resolved or reported later.
+ */
+ public void setThinking(String thinking) {
+ this.thinking = AiThinking.toCode(thinking);
+ }
+
+ /** The label the editor shows for the stored setting. */
+ public String getThinkingLabel() {
+ return AiThinking.toDescription(thinking);
+ }
+
+ /** The Thinking settings, by the label the editor shows. */
+ public List<String> getThinkingChoices(ILogChannel log, IHopMetadataProvider metadataProvider) {
+ return List.of(AiThinking.descriptions());
+ }
+
/**
* Initial combo items only (current value and plugin default). Live ids are loaded with Refresh
* models in the editor.
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiThinking.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiThinking.java
new file mode 100644
index 0000000000..6b09d8dfc8
--- /dev/null
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiThinking.java
@@ -0,0 +1,111 @@
+/*
+ * 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.hop.ai.metadata;
+
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IEnumHasCodeAndDescription;
+
+/**
+ * Whether a model reasons before it answers. Thinking models (qwen3, deepseek-r1, gpt-oss, ...)
+ * reason by default, which for a call per row dominates the run time.
+ *
+ * <p>Only Ollama applies it for now. The setting is generic so that the reasoning options of other
+ * provider types can map onto it later.
+ */
+public enum AiThinking implements IEnumHasCodeAndDescription {
+ /** Leave it to the model. The value for a provider saved before the option existed. */
+ DEFAULT(null),
+
+ /** Answer without reasoning first. */
+ OFF(Boolean.FALSE),
+
+ /** Reason before answering. */
+ ON(Boolean.TRUE);
+
+ private static final Class<?> PKG = AiThinking.class;
+
+ private final Boolean think;
+
+ AiThinking(Boolean think) {
+ this.think = think;
+ }
+
+ @Override
+ public String getCode() {
+ return name();
+ }
+
+ @Override
+ public String getDescription() {
+ return BaseMessages.getString(PKG, "AiThinking." + name());
+ }
+
+ /** The value for the model's {@code think} option, or null to leave it unset. */
+ public Boolean think() {
+ return think;
+ }
+
+ /**
+ * The setting a stored value stands for. A code or a label is accepted, both ignoring case: a
+ * provider saved before codes were stored holds the label.
+ *
+ * @param value the resolved field value
+ * @return the setting, {@link #DEFAULT} for an empty value, or null when the value is not one of
+ * the settings
+ */
+ public static AiThinking lookup(String value) {
+ if (Utils.isEmpty(value) || value.isBlank()) {
+ return DEFAULT;
+ }
+ String text = value.trim();
+ for (AiThinking thinking : values()) {
+ if (thinking.name().equalsIgnoreCase(text)
+ || thinking.getDescription().equalsIgnoreCase(text)) {
+ return thinking;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The code to store for a value: the code of the setting it names, or the value as it is when it
+ * is empty, a variable or not a setting.
+ */
+ public static String toCode(String value) {
+ if (value == null || value.isBlank()) {
+ return value;
+ }
+ AiThinking thinking = lookup(value);
+ return thinking == null ? value : thinking.getCode();
+ }
+
+ /** The label to show for a stored value, or the value as it is when it names no setting. */
+ public static String toDescription(String value) {
+ if (value == null || value.isBlank()) {
+ return value;
+ }
+ AiThinking thinking = lookup(value);
+ return thinking == null ? value : thinking.getDescription();
+ }
+
+ /** The labels the editor offers, in order. */
+ public static String[] descriptions() {
+ return IEnumHasCodeAndDescription.getDescriptions(AiThinking.class);
+ }
+}
diff --git a/plugins/tech/ai/src/main/resources/org/apache/hop/ai/metadata/messages/messages_en_US.properties b/plugins/tech/ai/src/main/resources/org/apache/hop/ai/metadata/messages/messages_en_US.properties
index 9d14abfb51..1f64200f28 100644
--- a/plugins/tech/ai/src/main/resources/org/apache/hop/ai/metadata/messages/messages_en_US.properties
+++ b/plugins/tech/ai/src/main/resources/org/apache/hop/ai/metadata/messages/messages_en_US.properties
@@ -51,9 +51,15 @@ AiProvider.ContextSize.Label=Context size (tokens)
AiProvider.ContextSize.Tooltip=How many tokens the model can read at once: prompt, history and answer together. Hop warns before sending a question that likely does not fit. For Ollama this also sets the context window the model is loaded with (num_ctx). Leave empty for 16384 with Ollama. For a hosted provider an empty field means the question is checked against 200000 tokens for Anthropic and 128000 for the others; set the model's real window if it is smaller. With Ollama a larger windo [...]
AiProvider.MaxOutputTokens.Label=Max output tokens
AiProvider.MaxOutputTokens.Tooltip=The longest answer the model may write, in tokens. Leave empty for the provider's default (4096 for Anthropic, which requires a value). Too low a value cuts answers and proposals short.
+AiProvider.Thinking.Label=Thinking
+AiProvider.Thinking.Tooltip=Whether a thinking model (qwen3, deepseek-r1, gpt-oss, ...) reasons before it answers. Default leaves it to the model, which for most thinking models means it reasons. Off answers right away, which is much faster for transforms that call the model once per row. On always reasons. With Off or On the reasoning is kept out of the answer; with Default the model and the Ollama version decide, and the answer can include <think> tags. Can be a variable such as '${AI_ [...]
+AiThinking.DEFAULT=Default
+AiThinking.OFF=Off
+AiThinking.ON=On
AiProvider.Validate.NoType=Select a provider type.
AiProvider.Validate.Temperature=Temperature ''{0}'' must be a number from 0 to 2.
AiProvider.Validate.PositiveNumber={0} ''{1}'' must be a whole number above 0.
+AiProvider.Validate.Thinking=Thinking ''{0}'' must be one of {1}.
AiProviderEditor.Validate.Title=The AI provider cannot be saved
AiProvider.StructuredAnswers.Label=Structured answers
AiProvider.StructuredAnswers.Tooltip=Have the AI Assistant answer as JSON that follows a fixed schema: the answer text and a list of proposals with a known type and their parameters. The provider holds the model to the schema, so the proposals cannot come back broken. Works with Ollama, OpenAI, Anthropic and Mistral; other provider types answer as usual. If the provider refuses the schema, the question is asked again without it. Check the answers of your models with the evaluation set be [...]
diff --git a/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiChatFactoryTest.java b/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiChatFactoryTest.java
index ba1f7d9dc8..922b6c9383 100644
--- a/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiChatFactoryTest.java
+++ b/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiChatFactoryTest.java
@@ -34,6 +34,7 @@ import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.variables.Variables;
import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
import org.apache.hop.pipeline.transforms.languagemodelchat.LanguageModelChatMeta;
+import org.apache.hop.pipeline.transforms.languagemodelchat.internals.OllamaThink;
import org.junit.jupiter.api.Test;
class AiChatFactoryTest {
@@ -120,6 +121,71 @@ class AiChatFactoryTest {
assertEquals(2000, configured.getOllamaNumPredict());
}
+ @Test
+ void ollamaThinkingReachesLanguageModelChat() throws Exception {
+ AiProvider ollama = new AiProvider();
+ OllamaProvider backend = new OllamaProvider();
+ backend.setPluginId("ollama");
+ ollama.setProvider(backend);
+
+ // Default sets nothing, so the model decides as it did before the option existed.
+ assertEquals(
+ OllamaThink.DEFAULT,
+ AiChatFactory.toLanguageModelChatMeta(ollama, new Variables()).getOllamaThink());
+
+ ollama.setThinking("Off");
+ assertEquals(
+ OllamaThink.OFF,
+ AiChatFactory.toLanguageModelChatMeta(ollama, new Variables()).getOllamaThink());
+
+ Variables variables = new Variables();
+ variables.setVariable("AI_THINKING", "On");
+ ollama.setThinking("${AI_THINKING}");
+ assertEquals(
+ OllamaThink.ON, AiChatFactory.toLanguageModelChatMeta(ollama, variables).getOllamaThink());
+ }
+
+ @Test
+ void overlayWithThinkingDefaultKeepsTheInlineThink() throws Exception {
+ MemoryMetadataProvider metadata = new MemoryMetadataProvider();
+ AiProvider provider = new AiProvider();
+ OllamaProvider backend = new OllamaProvider();
+ backend.setPluginId("ollama");
+ provider.setName("local");
+ provider.setProvider(backend);
+ metadata.getSerializer(AiProvider.class).save(provider);
+
+ LanguageModelChatMeta source = new LanguageModelChatMeta();
+ source.setDefault();
+ source.setOllamaThink(OllamaThink.OFF);
+
+ assertEquals(
+ OllamaThink.OFF,
+ AiChatFactory.overlayNamedProvider(source, "local", new Variables(), metadata)
+ .getOllamaThink());
+
+ provider.setThinking("On");
+ metadata.getSerializer(AiProvider.class).save(provider);
+ assertEquals(
+ OllamaThink.ON,
+ AiChatFactory.overlayNamedProvider(source, "local", new Variables(), metadata)
+ .getOllamaThink());
+ }
+
+ @Test
+ void thinkingIsIgnoredForOtherProviderTypes() throws Exception {
+ AiProvider openAi = new AiProvider();
+ OpenAiProvider backend = new OpenAiProvider();
+ backend.setPluginId("openai");
+ openAi.setProvider(backend);
+ openAi.setApiKey("sk-test");
+ openAi.setThinking("Off");
+
+ assertEquals(
+ OllamaThink.DEFAULT,
+ AiChatFactory.toLanguageModelChatMeta(openAi, new Variables()).getOllamaThink());
+ }
+
@Test
void anthropicAnswersAreNotCutAtTheOldDefault() throws Exception {
AiProvider anthropic = new AiProvider();
diff --git a/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiChatModelFactoryTest.java b/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiChatModelFactoryTest.java
new file mode 100644
index 0000000000..a66ec8e9ab
--- /dev/null
+++ b/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiChatModelFactoryTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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.hop.ai.engine;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.net.httpserver.HttpServer;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.ollama.OllamaChatRequestParameters;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.ai.metadata.AiProvider;
+import org.apache.hop.ai.providers.OllamaProvider;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The model Extract graph, Structured extract and the AI Assistant use, against a stub Ollama, so
+ * the Thinking option is checked in the request that is actually sent.
+ */
+class AiChatModelFactoryTest {
+
+ /** A thinking model answers with its reasoning in a field of its own. */
+ private static final String THINKING_ANSWER =
+ """
+ {"model":"qwen3","created_at":"2026-10-10T00:00:00Z",
+ "message":{"role":"assistant","content":"OK","thinking":"The user wants OK."},
+ "done":true,"done_reason":"stop","prompt_eval_count":11,"eval_count":22}
+ """;
+
+ private final AtomicReference<String> lastRequestBody = new AtomicReference<>();
+ private HttpServer server;
+ private String baseUrl;
+
+ @BeforeEach
+ void startServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+ server.createContext(
+ "/api/chat",
+ exchange -> {
+ try (InputStream in = exchange.getRequestBody()) {
+ lastRequestBody.set(new String(in.readAllBytes(), UTF_8));
+ }
+ byte[] bytes = THINKING_ANSWER.getBytes(UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, bytes.length);
+ exchange.getResponseBody().write(bytes);
+ exchange.close();
+ });
+ baseUrl = "http://localhost:" + server.getAddress().getPort();
+ server.start();
+ }
+
+ @AfterEach
+ void stopServer() {
+ server.stop(0);
+ }
+
+ @Test
+ void thinkingDefaultSendsNoThink() throws Exception {
+ ChatModel model = model("");
+
+ assertNull(((OllamaChatRequestParameters) model.defaultRequestParameters()).think());
+ model.chat("Reply with OK");
+ assertFalse(compactRequestBody().contains("\"think\""), lastRequestBody.get());
+ }
+
+ @Test
+ void thinkingOffSendsThinkFalse() throws Exception {
+ ChatModel model = model("Off");
+
+ assertEquals(
+ Boolean.FALSE, ((OllamaChatRequestParameters) model.defaultRequestParameters()).think());
+ model.chat("Reply with OK");
+ assertTrue(compactRequestBody().contains("\"think\":false"), lastRequestBody.get());
+ }
+
+ @Test
+ void thinkingOnSendsThinkTrue() throws Exception {
+ model("On").chat("Reply with OK");
+
+ assertTrue(compactRequestBody().contains("\"think\":true"), lastRequestBody.get());
+ }
+
+ @Test
+ void thinkingCanBeAVariable() throws Exception {
+ Variables variables = new Variables();
+ variables.setVariable("AI_THINKING", "off");
+ AiProvider provider = provider("${AI_THINKING}");
+
+ AiChatModelFactory.createChatModel(provider, "", variables).chat("Reply with OK");
+
+ assertTrue(compactRequestBody().contains("\"think\":false"), lastRequestBody.get());
+ }
+
+ /**
+ * The separate thinking field is never returned. With think unset, whether the answer text itself
+ * carries {@code <think>} tags is up to the model and the Ollama version, which this stub cannot
+ * show.
+ */
+ @Test
+ void theSeparateThinkingFieldIsNotReturned() throws Exception {
+ for (String thinking : new String[] {"", "Off", "On"}) {
+ ChatResponse response =
+ model(thinking)
+ .chat(ChatRequest.builder().messages(UserMessage.from("Reply with OK")).build());
+ assertEquals("OK", response.aiMessage().text(), thinking);
+ assertNull(response.aiMessage().thinking(), thinking);
+ }
+ }
+
+ private ChatModel model(String thinking) throws Exception {
+ return AiChatModelFactory.createChatModel(provider(thinking), "", new Variables());
+ }
+
+ private AiProvider provider(String thinking) {
+ AiProvider provider = new AiProvider();
+ provider.setName("ollama-test");
+ OllamaProvider backend = new OllamaProvider();
+ backend.setPluginId("ollama");
+ provider.setProvider(backend);
+ provider.setBaseUrl(baseUrl);
+ provider.setModelName("qwen3");
+ provider.setThinking(thinking);
+ return provider;
+ }
+
+ /** Request payloads are pretty printed, so they are matched without their whitespace. */
+ private String compactRequestBody() {
+ return lastRequestBody.get().replaceAll("\\s+", "");
+ }
+}
diff --git a/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiProviderSettingsTest.java b/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiProviderSettingsTest.java
index 0e80be7336..917c908331 100644
--- a/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiProviderSettingsTest.java
+++ b/plugins/tech/ai/src/test/java/org/apache/hop/ai/engine/AiProviderSettingsTest.java
@@ -124,6 +124,37 @@ class AiProviderSettingsTest {
assertTrue(e.getMessage().contains("half-configured"), e.getMessage());
}
+ @Test
+ void thinkingDefaultLeavesThinkUnset() throws Exception {
+ assertNull(AiProviderSettings.of(provider(p -> {}), vars()).think());
+ assertNull(AiProviderSettings.of(provider(p -> p.setThinking("Default")), vars()).think());
+ // A provider saved before the option existed has no value at all.
+ assertNull(AiProviderSettings.of(provider(p -> p.setThinking(null)), vars()).think());
+ }
+
+ @Test
+ void thinkingOffAndOnSetThink() throws Exception {
+ assertEquals(
+ Boolean.FALSE, AiProviderSettings.of(provider(p -> p.setThinking("Off")), vars()).think());
+ assertEquals(
+ Boolean.TRUE, AiProviderSettings.of(provider(p -> p.setThinking("ON")), vars()).think());
+ }
+
+ @Test
+ void thinkingResolvesAVariable() throws Exception {
+ IVariables variables = vars();
+ variables.setVariable("AI_THINKING", "off");
+
+ assertEquals(
+ Boolean.FALSE,
+ AiProviderSettings.of(provider(p -> p.setThinking("${AI_THINKING}")), variables).think());
+ }
+
+ @Test
+ void anUnknownThinkingValueLeavesThinkUnset() throws Exception {
+ assertNull(AiProviderSettings.of(provider(p -> p.setThinking("maybe")), vars()).think());
+ }
+
private static IVariables vars() {
return new Variables();
}
diff --git a/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiProviderSerializationTest.java b/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiProviderSerializationTest.java
index f37865679a..c8ae2c0c60 100644
--- a/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiProviderSerializationTest.java
+++ b/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiProviderSerializationTest.java
@@ -95,6 +95,41 @@ class AiProviderSerializationTest {
assertEquals("llama3.2", provider.getModelName());
}
+ @Test
+ void thinkingSurvivesASaveAndAnOldProviderLoadsWithout() throws Exception {
+ AiProvider old =
+ load(
+ "{\"name\":\"local\",\"provider\":{\"ollama\":{}},"
+ + "\"baseUrl\":\"http://localhost:11434\",\"modelName\":\"qwen3\"}");
+ assertEquals("", old.getThinking());
+
+ // The editor hands over the label; the code is what is saved.
+ old.setThinking("Off");
+ JSONObject saved = parser.getJsonObject(old);
+ assertEquals("OFF", saved.get("thinking"), saved.toJSONString());
+ assertEquals("OFF", load(saved.toJSONString()).getThinking());
+ }
+
+ @Test
+ void aProviderSavedWithTheLabelLoadsAsTheCode() throws Exception {
+ AiProvider provider =
+ load(
+ "{\"name\":\"local\",\"provider\":{\"ollama\":{}},"
+ + "\"modelName\":\"qwen3\",\"thinking\":\"Off\"}");
+
+ assertEquals("OFF", provider.getThinking());
+ }
+
+ @Test
+ void aThinkingVariableIsSavedAsTyped() throws Exception {
+ AiProvider provider =
+ load(
+ "{\"name\":\"local\",\"provider\":{\"ollama\":{}},"
+ + "\"modelName\":\"qwen3\",\"thinking\":\"${AI_THINKING}\"}");
+
+ assertEquals("${AI_THINKING}", parser.getJsonObject(provider).get("thinking"));
+ }
+
private static AiProvider load(String json) throws Exception {
try (JsonParser jsonParser = new JsonFactory().createParser(json)) {
jsonParser.nextToken();
diff --git a/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiProviderTest.java b/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiProviderTest.java
index 1ee37f03e6..38bf94cffe 100644
--- a/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiProviderTest.java
+++ b/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiProviderTest.java
@@ -105,6 +105,7 @@ class AiProviderTest {
source.setProvider(backend);
source.setApiKey("secret");
source.setModelName("gpt-4o-mini");
+ source.setThinking("Off");
AiProvider copy = new AiProvider(source);
assertEquals("prod-openai", copy.getName());
@@ -112,6 +113,61 @@ class AiProviderTest {
assertEquals("gpt-4o-mini", copy.getModelName());
assertEquals("openai", copy.getPluginId());
assertEquals("OpenAI", copy.getPluginName());
+ assertEquals("OFF", copy.getThinking());
+ }
+
+ @Test
+ void thinkingStoresTheCodeAndShowsTheLabel() {
+ AiProvider provider = ollama();
+
+ provider.setThinking(AiThinking.OFF.getDescription());
+ assertEquals("OFF", provider.getThinking());
+ assertEquals("Off", provider.getThinkingLabel());
+
+ provider.setThinking("on");
+ assertEquals("ON", provider.getThinking());
+ assertEquals("On", provider.getThinkingLabel());
+
+ // A variable is resolved when the provider is used, so it is stored as typed.
+ provider.setThinking("${AI_THINKING}");
+ assertEquals("${AI_THINKING}", provider.getThinking());
+ assertEquals("${AI_THINKING}", provider.getThinkingLabel());
+
+ provider.setThinking("");
+ assertEquals("", provider.getThinking());
+ assertEquals("", provider.getThinkingLabel());
+ }
+
+ @Test
+ void thinkingAcceptsEachSettingAVariableOrNothing() {
+ for (String value : new String[] {"", "Default", "Off", "On", "off", "ON", "${AI_THINKING}"}) {
+ AiProvider provider = ollama();
+ provider.setThinking(value);
+ assertTrue(provider.validate().isEmpty(), value + ": " + provider.validate());
+ }
+ }
+
+ @Test
+ void anUnknownThinkingValueIsReported() {
+ AiProvider provider = ollama();
+ provider.setThinking("maybe");
+
+ List<String> problems = provider.validate();
+ assertEquals(1, problems.size(), problems.toString());
+ assertTrue(problems.get(0).contains("maybe"), problems.get(0));
+ }
+
+ @Test
+ void thinkingChoicesAreTheSettingsInOrder() {
+ assertEquals(List.of("Default", "Off", "On"), ollama().getThinkingChoices(null, null));
+ }
+
+ private static AiProvider ollama() {
+ AiProvider provider = new AiProvider();
+ OllamaProvider backend = new OllamaProvider();
+ backend.setPluginId("ollama");
+ provider.setProvider(backend);
+ return provider;
}
@Test
diff --git a/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiThinkingTest.java b/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiThinkingTest.java
new file mode 100644
index 0000000000..3f496d13d1
--- /dev/null
+++ b/plugins/tech/ai/src/test/java/org/apache/hop/ai/metadata/AiThinkingTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.hop.ai.metadata;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+
+class AiThinkingTest {
+
+ @Test
+ void emptyMeansDefault() {
+ assertEquals(AiThinking.DEFAULT, AiThinking.lookup(null));
+ assertEquals(AiThinking.DEFAULT, AiThinking.lookup(""));
+ assertEquals(AiThinking.DEFAULT, AiThinking.lookup(" "));
+ }
+
+ @Test
+ void readsBackTheLabelAndTheCodeIgnoringCase() {
+ assertEquals(AiThinking.OFF, AiThinking.lookup(AiThinking.OFF.getDescription()));
+ assertEquals(AiThinking.OFF, AiThinking.lookup("OFF"));
+ assertEquals(AiThinking.ON, AiThinking.lookup(" on "));
+ assertEquals(AiThinking.DEFAULT, AiThinking.lookup("default"));
+ }
+
+ @Test
+ void anythingElseIsNotASetting() {
+ assertNull(AiThinking.lookup("maybe"));
+ assertNull(AiThinking.lookup("${AI_THINKING}"));
+ }
+
+ @Test
+ void eachSettingMapsOntoThink() {
+ assertNull(AiThinking.DEFAULT.think());
+ assertEquals(Boolean.FALSE, AiThinking.OFF.think());
+ assertEquals(Boolean.TRUE, AiThinking.ON.think());
+ }
+
+ @Test
+ void aRecognisedValueIsStoredAsItsCode() {
+ assertEquals("OFF", AiThinking.toCode("Off"));
+ assertEquals("ON", AiThinking.toCode(" on "));
+ assertEquals("DEFAULT", AiThinking.toCode("Default"));
+ assertEquals("OFF", AiThinking.toCode("OFF"));
+ }
+
+ @Test
+ void anythingElseIsStoredAsItIs() {
+ assertEquals("${AI_THINKING}", AiThinking.toCode("${AI_THINKING}"));
+ assertEquals("maybe", AiThinking.toCode("maybe"));
+ assertEquals("", AiThinking.toCode(""));
+ assertNull(AiThinking.toCode(null));
+ }
+
+ @Test
+ void aStoredCodeIsShownAsItsLabel() {
+ assertEquals(AiThinking.OFF.getDescription(), AiThinking.toDescription("OFF"));
+ assertEquals("${AI_THINKING}", AiThinking.toDescription("${AI_THINKING}"));
+ assertEquals("", AiThinking.toDescription(""));
+ }
+
+ @Test
+ void theCodeIsTheConstantName() {
+ for (AiThinking thinking : AiThinking.values()) {
+ assertEquals(thinking.name(), thinking.getCode());
+ assertEquals(thinking.name(), thinking.toString());
+ }
+ }
+}
diff --git a/plugins/tech/ai/src/test/java/org/apache/hop/ai/transforms/structuredextract/StructuredExtractThinkingTest.java b/plugins/tech/ai/src/test/java/org/apache/hop/ai/transforms/structuredextract/StructuredExtractThinkingTest.java
new file mode 100644
index 0000000000..60eabccd6b
--- /dev/null
+++ b/plugins/tech/ai/src/test/java/org/apache/hop/ai/transforms/structuredextract/StructuredExtractThinkingTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.hop.ai.transforms.structuredextract;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.net.httpserver.HttpServer;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.ai.engine.AiChatModelFactory;
+import org.apache.hop.ai.metadata.AiProvider;
+import org.apache.hop.ai.providers.OllamaProvider;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The provider's Thinking option on the request Structured extract actually sends: built by its own
+ * request builder and held to a JSON schema, against a stub Ollama. The integration test cannot see
+ * the flag, because Structured extract reports no token counts.
+ */
+class StructuredExtractThinkingTest {
+
+ private final AtomicReference<String> lastRequestBody = new AtomicReference<>();
+ private HttpServer server;
+ private String baseUrl;
+
+ @BeforeEach
+ void startServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+ server.createContext(
+ "/api/chat",
+ exchange -> {
+ try (InputStream in = exchange.getRequestBody()) {
+ lastRequestBody.set(new String(in.readAllBytes(), UTF_8));
+ }
+ byte[] bytes =
+ """
+ {"model":"qwen3","created_at":"2026-10-10T00:00:00Z",
+ "message":{"role":"assistant","content":"{\\"severity\\":\\"high\\"}"},
+ "done":true,"done_reason":"stop","prompt_eval_count":11,"eval_count":5}
+ """
+ .getBytes(UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, bytes.length);
+ exchange.getResponseBody().write(bytes);
+ exchange.close();
+ });
+ baseUrl = "http://localhost:" + server.getAddress().getPort();
+ server.start();
+ }
+
+ @AfterEach
+ void stopServer() {
+ server.stop(0);
+ }
+
+ @Test
+ void thinkingOffIsSentAlongsideTheSchema() throws Exception {
+ String body = send("OFF");
+
+ assertTrue(body.contains("\"think\":false"), lastRequestBody.get());
+ assertTrue(body.contains("\"format\":{"), lastRequestBody.get());
+ assertTrue(body.contains("\"severity\""), lastRequestBody.get());
+ }
+
+ @Test
+ void thinkingOnIsSentAlongsideTheSchema() throws Exception {
+ String body = send("ON");
+
+ assertTrue(body.contains("\"think\":true"), lastRequestBody.get());
+ assertTrue(body.contains("\"format\":{"), lastRequestBody.get());
+ }
+
+ @Test
+ void thinkingDefaultSendsTheSchemaWithoutThink() throws Exception {
+ String body = send("");
+
+ assertFalse(body.contains("\"think\""), lastRequestBody.get());
+ assertTrue(body.contains("\"format\":{"), lastRequestBody.get());
+ }
+
+ /** Sends one row's request the way Structured extract does, and returns the compacted body. */
+ private String send(String thinking) throws Exception {
+ AiProvider provider = new AiProvider();
+ provider.setName("ollama-test");
+ OllamaProvider backend = new OllamaProvider();
+ backend.setPluginId("ollama");
+ provider.setProvider(backend);
+ provider.setBaseUrl(baseUrl);
+ provider.setModelName("qwen3");
+ provider.setThinking(thinking);
+
+ JsonSchema schema =
+ ExtractionSchema.build(
+ List.of(new StructuredExtractField("severity", "String", "how urgent", true)),
+ "Structured extract");
+ ChatModel model = AiChatModelFactory.createChatModel(provider, "", new Variables());
+ ResponseFormat format = StructuredExtract.responseFormatFor(model, schema);
+ ChatRequest request = StructuredExtract.buildRequest("system", "a ticket", format);
+
+ assertEquals("{\"severity\":\"high\"}", model.chat(request).aiMessage().text());
+ return lastRequestBody.get().replaceAll("\\s+", "");
+ }
+}
diff --git a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChatMeta.java b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChatMeta.java
index bb7eba3ea7..83aac9ad8c 100644
--- a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChatMeta.java
+++ b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChatMeta.java
@@ -51,6 +51,7 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
import org.apache.hop.pipeline.PipelineMeta;
import org.apache.hop.pipeline.transform.BaseTransformMeta;
import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.languagemodelchat.internals.OllamaThink;
@Transform(
id = "LanguageModelChat",
@@ -163,6 +164,10 @@ public class LanguageModelChatMeta
@HopMetadataProperty private Integer ollamaNumCtx;
// TODO private List<String> ollamaStop;
@HopMetadataProperty private String ollamaFormat;
+
+ /** Whether a thinking model reasons before it answers. Default leaves it to the model. */
+ @HopMetadataProperty private OllamaThink ollamaThink = OllamaThink.DEFAULT;
+
@HopMetadataProperty private Integer ollamaTimeout = 60;
@HopMetadataProperty private Integer ollamaMaxRetries = 3;
diff --git a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelFacade.java b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelFacade.java
index 602f22e629..dc76bfcc6f 100644
--- a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelFacade.java
+++ b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelFacade.java
@@ -185,6 +185,7 @@ public class LanguageModelFacade {
Integer numPredict = meta.getOllamaNumPredict();
Integer numCtx = meta.getOllamaNumCtx();
String format = meta.getOllamaFormat();
+ Boolean think = OllamaThink.think(meta.getOllamaThink());
Integer timeout = meta.getOllamaTimeout();
Integer maxRetries = meta.getOllamaMaxRetries();
@@ -199,6 +200,10 @@ public class LanguageModelFacade {
.numPredict(numPredict)
.numCtx(numCtx)
.responseFormat(toResponseFormat(format))
+ // Null leaves think unset, so the model and the Ollama version decide, and the answer may
+ // carry <think> tags as before. returnThinking(false) only drops the separate field.
+ .think(think)
+ .returnThinking(false)
.timeout(timeout == null ? null : ofSeconds(timeout))
.maxRetries(maxRetries)
.httpClientBuilder(jdkHttpClientBuilder())
diff --git a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/OllamaThink.java b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/OllamaThink.java
new file mode 100644
index 0000000000..7f9d1f627e
--- /dev/null
+++ b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/OllamaThink.java
@@ -0,0 +1,73 @@
+/*
+ * 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.hop.pipeline.transforms.languagemodelchat.internals;
+
+import static org.apache.hop.pipeline.transforms.languagemodelchat.internals.ui.i18nUtil.i18n;
+
+import org.apache.hop.metadata.api.IEnumHasCodeAndDescription;
+
+/**
+ * Whether an Ollama thinking model (qwen3, deepseek-r1, gpt-oss, ...) reasons before it answers.
+ *
+ * <p>Three states rather than a Boolean: a transform saved before the option existed has no value,
+ * and that has to stay "leave it to the model", not turn thinking off.
+ */
+public enum OllamaThink implements IEnumHasCodeAndDescription {
+ /** Leave it to the model: {@code think} is not sent. */
+ DEFAULT(null),
+
+ /** {@code think: false}. */
+ OFF(Boolean.FALSE),
+
+ /** {@code think: true}. */
+ ON(Boolean.TRUE);
+
+ private final Boolean think;
+
+ OllamaThink(Boolean think) {
+ this.think = think;
+ }
+
+ @Override
+ public String getCode() {
+ return name();
+ }
+
+ @Override
+ public String getDescription() {
+ return i18n("LanguageModelChatDialog.OLLAMA.Think." + name());
+ }
+
+ /** The value for the model's {@code think} option, or null to leave it unset. */
+ public Boolean think() {
+ return think;
+ }
+
+ /** The setting for a {@code think} value; null is {@link #DEFAULT}. */
+ public static OllamaThink of(Boolean think) {
+ if (think == null) {
+ return DEFAULT;
+ }
+ return think ? ON : OFF;
+ }
+
+ /** The {@code think} value of a setting, where null, as an older transform loads, is unset. */
+ public static Boolean think(OllamaThink setting) {
+ return setting == null ? null : setting.think();
+ }
+}
diff --git a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OllamaComposite.java b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OllamaComposite.java
index 409bb01ab2..22af244e52 100644
--- a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OllamaComposite.java
+++ b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OllamaComposite.java
@@ -22,8 +22,11 @@ import static org.apache.commons.lang3.StringUtils.trim;
import static org.apache.commons.lang3.math.NumberUtils.isCreatable;
import static org.apache.hop.pipeline.transforms.languagemodelchat.internals.ModelType.OLLAMA;
+import org.apache.hop.metadata.api.IEnumHasCodeAndDescription;
+import org.apache.hop.pipeline.transforms.languagemodelchat.internals.OllamaThink;
import org.apache.hop.pipeline.transforms.languagemodelchat.internals.ui.CompositeParameters;
import org.apache.hop.ui.core.widget.TextVar;
+import org.eclipse.swt.custom.CCombo;
import org.eclipse.swt.widgets.Label;
public class OllamaComposite extends AbstractModelComposite {
@@ -46,6 +49,8 @@ public class OllamaComposite extends AbstractModelComposite {
private final TextVar numPredictInput;
private final Label numCtxLabel;
private final TextVar numCtxInput;
+ private final Label thinkLabel;
+ private final CCombo thinkInput;
private final Label formatLabel;
private final TextVar formatInput;
private final Label timeoutLabel;
@@ -102,6 +107,13 @@ public class OllamaComposite extends AbstractModelComposite {
numCtxInput = createTextVar();
prepare("NumCtx", numCtxLabel, numCtxInput);
+ // Think: Default leaves it to the model, Off and On set it
+ thinkLabel = createLabel();
+ thinkInput = createComboBox();
+ thinkInput.setItems(IEnumHasCodeAndDescription.getDescriptions(OllamaThink.class));
+ thinkInput.select(0);
+ prepare("Think", thinkLabel, thinkInput);
+
// Format
formatLabel = createLabel();
formatInput = createTextVar();
@@ -150,6 +162,7 @@ public class OllamaComposite extends AbstractModelComposite {
if (meta.getOllamaNumCtx() != null) {
numCtxInput.setText("" + meta.getOllamaNumCtx());
}
+ thinkInput.select(thinkIndex(meta.getOllamaThink()));
if (meta.getOllamaFormat() != null) {
formatInput.setText(meta.getOllamaFormat());
}
@@ -192,12 +205,32 @@ public class OllamaComposite extends AbstractModelComposite {
meta.setOllamaSeed(trimIntegerToNull(seedInput.getText()));
meta.setOllamaNumPredict(trimIntegerToNull(numPredictInput.getText()));
meta.setOllamaNumCtx(trimIntegerToNull(numCtxInput.getText()));
+ meta.setOllamaThink(think(thinkInput.getSelectionIndex()));
// TODO meta.setOllamaStop();
meta.setOllamaFormat(trimStringToNull(formatInput.getText())); // TODO Review
meta.setOllamaTimeout(trimIntegerToNull(timeoutInput.getText()));
meta.setOllamaMaxRetries(trimIntegerToNull(maxRetriesInput.getText()));
}
+ /** The combo entry for a setting. An older transform loads without one: Default. */
+ static int thinkIndex(OllamaThink think) {
+ return think == null ? OllamaThink.DEFAULT.ordinal() : think.ordinal();
+ }
+
+ /** The setting for a combo entry. No selection leaves it to the model. */
+ static OllamaThink think(int index) {
+ OllamaThink[] values = OllamaThink.values();
+ return index >= 0 && index < values.length ? values[index] : OllamaThink.DEFAULT;
+ }
+
+ public Label getThinkLabel() {
+ return thinkLabel;
+ }
+
+ public CCombo getThinkInput() {
+ return thinkInput;
+ }
+
public Label getNumPredictLabel() {
return numPredictLabel;
}
diff --git a/plugins/transforms/languagemodelchat/src/main/resources/org/apache/hop/pipeline/transforms/languagemodelchat/messages/messages_en_US.properties b/plugins/transforms/languagemodelchat/src/main/resources/org/apache/hop/pipeline/transforms/languagemodelchat/messages/messages_en_US.properties
index 4ad30498e7..8faffc9f1d 100644
--- a/plugins/transforms/languagemodelchat/src/main/resources/org/apache/hop/pipeline/transforms/languagemodelchat/messages/messages_en_US.properties
+++ b/plugins/transforms/languagemodelchat/src/main/resources/org/apache/hop/pipeline/transforms/languagemodelchat/messages/messages_en_US.properties
@@ -133,6 +133,11 @@ LanguageModelChatDialog.OLLAMA.NumPredict.Label=Max Tokens
LanguageModelChatDialog.OLLAMA.NumPredict.Tooltip=Sets a limit on how many tokens the model may generate in one response.
LanguageModelChatDialog.OLLAMA.NumCtx.Label=Context Window Size
LanguageModelChatDialog.OLLAMA.NumCtx.Tooltip=Specifies how much input tokens the model can consider at once.
+LanguageModelChatDialog.OLLAMA.Think.Label=Thinking
+LanguageModelChatDialog.OLLAMA.Think.Tooltip=Whether a thinking model (qwen3, deepseek-r1, gpt-oss, ...) reasons before it answers. Default leaves it to the model. Off answers right away, which is much faster when the model is called once per row. On always reasons. With Off or On the reasoning is kept out of the answer; with Default the model and the Ollama version decide, and the answer can include <think> tags.
+LanguageModelChatDialog.OLLAMA.Think.DEFAULT=Default
+LanguageModelChatDialog.OLLAMA.Think.OFF=Off
+LanguageModelChatDialog.OLLAMA.Think.ON=On
LanguageModelChatDialog.OLLAMA.Format.Label=Format
LanguageModelChatDialog.OLLAMA.Format.Tooltip=Specifies the response format, such as plain text or JSON.
LanguageModelChatDialog.OLLAMA.Timeout.Label=Timeout
diff --git a/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChatMetaThinkTest.java b/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChatMetaThinkTest.java
new file mode 100644
index 0000000000..3e71f28427
--- /dev/null
+++ b/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChatMetaThinkTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.hop.pipeline.transforms.languagemodelchat;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.transforms.languagemodelchat.internals.OllamaThink;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.w3c.dom.Node;
+
+/** The Ollama Thinking option is saved with the transform, and an older transform has none. */
+class LanguageModelChatMetaThinkTest {
+
+ @RegisterExtension
+ static RestoreHopEngineEnvironmentExtension env = new RestoreHopEngineEnvironmentExtension();
+
+ @BeforeAll
+ static void init() throws Exception {
+ HopClientEnvironment.init();
+ }
+
+ @Test
+ void thinkIsLeftToTheModelByDefault() {
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ meta.setDefault();
+ assertEquals(OllamaThink.DEFAULT, meta.getOllamaThink());
+ assertNull(OllamaThink.think(meta.getOllamaThink()));
+ }
+
+ @Test
+ void thinkSurvivesASave() throws Exception {
+ for (OllamaThink think : OllamaThink.values()) {
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ meta.setOllamaThink(think);
+ String xml = meta.getXml();
+ assertTrue(xml.contains("<ollamaThink>" + think.name() + "</ollamaThink>"), xml);
+ assertEquals(think, fromXml(xml).getOllamaThink());
+ }
+ }
+
+ @Test
+ void aTransformSavedBeforeTheOptionLeavesItToTheModel() throws Exception {
+ String xml =
+ new LanguageModelChatMeta().getXml().replaceAll("<ollamaThink>[^<]*</ollamaThink>", "");
+ assertFalse(xml.contains("ollamaThink"), xml);
+ // Not Off: a missing value must not switch thinking off.
+ assertNull(OllamaThink.think(fromXml(xml).getOllamaThink()));
+ }
+
+ @Test
+ void eachSettingMapsOntoThink() {
+ assertNull(OllamaThink.think(null));
+ assertNull(OllamaThink.DEFAULT.think());
+ assertEquals(Boolean.FALSE, OllamaThink.OFF.think());
+ assertEquals(Boolean.TRUE, OllamaThink.ON.think());
+ assertEquals(OllamaThink.DEFAULT, OllamaThink.of(null));
+ assertEquals(OllamaThink.OFF, OllamaThink.of(false));
+ assertEquals(OllamaThink.ON, OllamaThink.of(true));
+ }
+
+ private LanguageModelChatMeta fromXml(String xml) throws Exception {
+ Node node =
+ XmlHandler.loadXmlString(
+ XmlHandler.openTag("transform") + xml + XmlHandler.closeTag("transform"), "transform");
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ meta.loadXml(node, new MemoryMetadataProvider());
+ return meta;
+ }
+}
diff --git a/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelFacadeChatTest.java b/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelFacadeChatTest.java
index 25f8757a18..f4c07716f8 100644
--- a/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelFacadeChatTest.java
+++ b/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelFacadeChatTest.java
@@ -19,6 +19,8 @@ package org.apache.hop.pipeline.transforms.languagemodelchat.internals;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.sun.net.httpserver.HttpServer;
@@ -136,6 +138,39 @@ class LanguageModelFacadeChatTest {
assertTrue(compactRequestBody().contains("\"format\":\"json\""), lastRequestBody.get());
}
+ @Test
+ void ollamaThink() throws Exception {
+ // A thinking model answers with its reasoning in a field of its own.
+ respondWith(
+ "/api/chat",
+ """
+ {"model":"qwen3","created_at":"2026-10-10T00:00:00Z",
+ "message":{"role":"assistant","content":"four","thinking":"Two and two make four."},
+ "done":true,"done_reason":"stop","prompt_eval_count":11,"eval_count":22}
+ """);
+
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ meta.setModelType(ModelType.OLLAMA.code());
+ meta.setOllamaImageEndpoint(baseUrl);
+ meta.setOllamaModelName("qwen3");
+
+ // Not set: the model decides, as before the option existed.
+ ChatResponse response = chat(meta, "two plus two");
+ assertFalse(compactRequestBody().contains("\"think\""), lastRequestBody.get());
+ assertEquals("four", response.aiMessage().text());
+ assertNull(response.aiMessage().thinking());
+
+ meta.setOllamaThink(OllamaThink.OFF);
+ chat(meta, "two plus two");
+ assertTrue(compactRequestBody().contains("\"think\":false"), lastRequestBody.get());
+
+ meta.setOllamaThink(OllamaThink.ON);
+ response = chat(meta, "two plus two");
+ assertTrue(compactRequestBody().contains("\"think\":true"), lastRequestBody.get());
+ assertEquals("four", response.aiMessage().text());
+ assertNull(response.aiMessage().thinking());
+ }
+
@Test
void anthropic() throws Exception {
respondWith(
diff --git a/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OllamaCompositeThinkTest.java b/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OllamaCompositeThinkTest.java
new file mode 100644
index 0000000000..b77cb6d8c1
--- /dev/null
+++ b/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OllamaCompositeThinkTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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.hop.pipeline.transforms.languagemodelchat.internals.ui.models;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.hop.pipeline.transforms.languagemodelchat.internals.OllamaThink;
+import org.junit.jupiter.api.Test;
+
+/** The Thinking combo lists the settings in order, and reads each one back. */
+class OllamaCompositeThinkTest {
+
+ @Test
+ void eachEntryReadsBackAsItsSetting() {
+ for (OllamaThink think : OllamaThink.values()) {
+ assertEquals(think, OllamaComposite.think(OllamaComposite.thinkIndex(think)));
+ }
+ }
+
+ @Test
+ void anOlderTransformShowsDefault() {
+ assertEquals(OllamaThink.DEFAULT.ordinal(), OllamaComposite.thinkIndex(null));
+ }
+
+ @Test
+ void noSelectionLeavesItToTheModel() {
+ assertEquals(OllamaThink.DEFAULT, OllamaComposite.think(-1));
+ }
+}