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 e570d5c98d Fixes #8336 : Upgrade the Language Model Chat transform to
langchain4j 1.20.0 (#8341)
e570d5c98d is described below
commit e570d5c98da6c1b99200c6d98b3afbba82299cda
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Sep 12 13:31:09 2026 +0200
Fixes #8336 : Upgrade the Language Model Chat transform to langchain4j
1.20.0 (#8341)
Move from langchain4j 0.29.1 to 1.20.0: ChatLanguageModel becomes ChatModel,
generate() becomes chat() returning ChatResponse, and the removed
ChatMessage.text()
is replaced by a helper that collects the text parts of a multimodal
message.
OpenAI proxy support moves to the JDK http client builder, and the free
format
string of Ollama and Mistral is mapped onto a ResponseFormat.
The Hugging Face integration no longer uses langchain4j-hugging-face, a beta
artifact that upstream deprecated for removal. The four vendored classes
under
dev.langchain4j are replaced by a client in the plugin's own package,
calling the
API over the langchain4j http client. This drops retrofit, okhttp-sse, gson
and
the abandoned openai4j from the distribution.
Api keys and access tokens are stored as passwords and masked in the
dialog, the
way Hop stores other secrets. Values holding variables are still written as
they
stand. The openAiApiKey field default lost a stray brace it carried since
the
transform was contributed.
Adds a stub server test covering all five providers and an Ollama
integration
test project, disabled by default because it pulls a model.
---
assemblies/static/src/main/resources/LICENSE | 35 ++-
.../integration-tests-languagemodelchat.yaml | 48 ++++
.../pipeline/transforms/languagemodelchat.adoc | 8 +-
.../languagemodelchat/0001-ollama-chat.hpl | 311 +++++++++++++++++++++
integration-tests/languagemodelchat/README.md | 57 ++++
.../languagemodelchat/dev-env-config.json | 14 +
integration-tests/languagemodelchat/disabled.txt | 21 ++
.../languagemodelchat/hop-config.json | 290 +++++++++++++++++++
.../languagemodelchat/main-0001-ollama-chat.hwf | 89 ++++++
.../metadata/pipeline-run-configuration/local.json | 17 ++
.../metadata/workflow-run-configuration/local.json | 9 +
.../languagemodelchat/project-config.json | 15 +
plugins/transforms/languagemodelchat/pom.xml | 65 +----
.../DedicatedEndpointFactoryCreator.java | 46 ---
.../DedicatedEndpointHuggingFaceApi.java | 38 ---
.../DedicatedEndpointHuggingFaceChatModel.java | 178 ------------
.../DedicatedEndpointHuggingFaceClient.java | 123 --------
.../languagemodelchat/LanguageModelChat.java | 9 +-
.../languagemodelchat/LanguageModelChatMeta.java | 48 ++--
.../languagemodelchat/internals/ChatMessages.java | 57 ++++
.../internals/LanguageModelChatModelName.java | 43 +--
.../internals/LanguageModelFacade.java | 99 +++----
.../huggingface/HuggingFaceChatModel.java | 139 +++++++++
.../internals/huggingface/HuggingFaceClient.java | 146 ++++++++++
.../ui/models/AbstractModelComposite.java | 6 +
.../internals/ui/models/AnthropicComposite.java | 2 +-
.../internals/ui/models/HuggingFaceComposite.java | 2 +-
.../internals/ui/models/MistralComposite.java | 2 +-
.../internals/ui/models/OpenAiComposite.java | 2 +-
.../fact-checking/bespoke-minicheck.hpl | 4 +-
.../languagemodelchat/zero-shot/anthropic.hpl | 2 +-
.../languagemodelchat/zero-shot/huggingface.hpl | 2 +-
.../languagemodelchat/zero-shot/openai.hpl | 2 +-
.../LanguageModelChatMetaSecretsTest.java | 117 ++++++++
.../internals/LanguageModelFacadeChatTest.java | 211 ++++++++++++++
35 files changed, 1692 insertions(+), 565 deletions(-)
diff --git a/assemblies/static/src/main/resources/LICENSE
b/assemblies/static/src/main/resources/LICENSE
index aa95690231..b45ccefa5c 100644
--- a/assemblies/static/src/main/resources/LICENSE
+++ b/assemblies/static/src/main/resources/LICENSE
@@ -766,9 +766,6 @@ This product bundles conscrypt-openjdk-uber 2.5.2, which is
available under a
This product bundles content-type 2.3, which is available under a
"Apache 2.0" license. For details, see LICENSE.
-This product bundles converter-gson 2.9.0, which is available under a
-"Apache 2.0" license. For details, see LICENSE.
-
This product bundles converter-jackson 2.7.2, which is available under a
"Apache 2.0" license. For details, see LICENSE.
@@ -1885,7 +1882,7 @@ This product bundles jsr305 3.0.2, which is available
under a
This product bundles jt400 21.0.7, which is available under a
"IBM Public 1.0" license. For details, see licenses/LICENSE-IBM10.
-This product bundles jtokkit 1.0.0, which is available under a
+This product bundles jtokkit 1.1.0, which is available under a
"The MIT" license. For details, see licenses/LICENSE-MIT.
This product bundles jts-core 1.20.0, which is available under a
@@ -2008,22 +2005,28 @@ This product bundles kryo-shaded 4.0.3, which is
available under a
This product bundles lang-tag 1.7, which is available under a
"Apache 2.0" license. For details, see LICENSE.
-This product bundles langchain4j-anthropic 0.29.1, which is available under a
+This product bundles langchain4j-anthropic 1.20.0, which is available under a
+"Apache 2.0" license. For details, see LICENSE.
+
+This product bundles langchain4j-core 1.20.0, which is available under a
"Apache 2.0" license. For details, see LICENSE.
-This product bundles langchain4j-core 0.29.1, which is available under a
+This product bundles langchain4j-http-client 1.20.0, which is available under a
"Apache 2.0" license. For details, see LICENSE.
-This product bundles langchain4j-hugging-face 0.29.1, which is available under
a
+This product bundles langchain4j-http-client-jdk 1.20.0, which is available
under a
"Apache 2.0" license. For details, see LICENSE.
-This product bundles langchain4j-mistral-ai 0.29.1, which is available under a
+This product bundles langchain4j-mistral-ai 1.20.0, which is available under a
"Apache 2.0" license. For details, see LICENSE.
-This product bundles langchain4j-ollama 0.29.1, which is available under a
+This product bundles langchain4j-ollama 1.20.0, which is available under a
"Apache 2.0" license. For details, see LICENSE.
-This product bundles langchain4j-open-ai 0.29.1, which is available under a
+This product bundles langchain4j-open-ai 1.20.0, which is available under a
+"Apache 2.0" license. For details, see LICENSE.
+
+This product bundles langchain4j-reactive-streaming 1.20.0-beta30, which is
available under a
"Apache 2.0" license. For details, see LICENSE.
This product bundles LatencyUtils 2.0.3, which is available under a
@@ -2218,6 +2221,9 @@ This product bundles mvel2 2.5.2.Final, which is
available under a
This product bundles mxdump 0.14, which is available under a
"Apache 2.0" license. For details, see LICENSE.
+This product bundles mutiny-zero 1.3.1, which is available under a
+"Apache 2.0" license. For details, see LICENSE.
+
This product bundles native-protocol 1.5.1, which is available under a
"Apache 2.0" license. For details, see LICENSE.
@@ -2425,9 +2431,6 @@ This product bundles ohc-core-j8 0.5.1, which is
available under a
This product bundles okhttp 4.12.0, which is available under a
"Apache 2.0" license. For details, see LICENSE.
-This product bundles okhttp-sse 4.12.0, which is available under a
-"Apache 2.0" license. For details, see LICENSE.
-
This product bundles okhttp-urlconnection 3.12.2, which is available under a
"Apache 2.0" license. For details, see LICENSE.
@@ -2443,9 +2446,6 @@ This product bundles okio-fakefilesystem-jvm 3.4.0, which
is available under a
This product bundles okio-jvm 3.18.1, which is available under a
"Apache 2.0" license. For details, see LICENSE.
-This product bundles openai4j 0.17.0, which is available under a
-"Apache 2.0" license. For details, see LICENSE.
-
This product bundles opencensus-api 0.31.1, which is available under a
"Apache 2.0" license. For details, see LICENSE.
@@ -2800,9 +2800,6 @@ This product bundles reporter-config3 3.0.3, which is
available under a
This product bundles retrofit 2.7.2, which is available under a
"Apache 2.0" license. For details, see LICENSE.
-This product bundles retrofit 2.9.0, which is available under a
-"Apache 2.0" license. For details, see LICENSE.
-
This product bundles rgxgen 3.1, which is available under a
"Apache 2.0" license. For details, see LICENSE.
diff --git a/docker/integration-tests/integration-tests-languagemodelchat.yaml
b/docker/integration-tests/integration-tests-languagemodelchat.yaml
new file mode 100644
index 0000000000..7a1f8f6c81
--- /dev/null
+++ b/docker/integration-tests/integration-tests-languagemodelchat.yaml
@@ -0,0 +1,48 @@
+# 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.
+
+services:
+ integration_test_languagemodelchat:
+ extends:
+ file: integration-tests-base.yaml
+ service: integration_test
+ depends_on:
+ ollama:
+ condition: service_healthy
+ links:
+ - ollama
+
+ ollama:
+ # Pin version for CI stability; tests reach it over compose DNS
(ollama:11434).
+ image: ollama/ollama:0.34.0
+ hostname: ollama
+ # The image ships no models, so the server is started and the test model
pulled here. The
+ # healthcheck below is what holds the test container back until that pull
has finished.
+ entrypoint: ["/bin/sh", "-c"]
+ command:
+ - |
+ ollama serve &
+ until ollama list >/dev/null 2>&1; do sleep 1; done
+ ollama pull smollm2:135m
+ wait
+ healthcheck:
+ # smollm2:135m is ~270MB: small enough to pull on every run, large
enough to answer a prompt.
+ test: ["CMD-SHELL", "ollama list | grep -q smollm2"]
+ interval: 10s
+ timeout: 10s
+ retries: 60
+ start_period: 20s
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 be5aa622c1..7517380483 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
@@ -121,7 +121,7 @@ Below is a table detailing parameters specific to
individual language model APIs
|Option |Description
|API/Endpoint URL
-|Specifies the identifier (e.g., URL, model ID, or image ID) used to access
and interact with models provided by services like OpenAI, Anthropic, Mistral,
Ollama, or Hugging Face.
+|Specifies the identifier (e.g., URL, model ID, or image ID) used to access
and interact with models provided by services like OpenAI, Anthropic, Mistral,
Ollama, or Hugging Face. For Hugging Face, a model id is sent to the Hugging
Face inference router, a URL is called as a dedicated inference endpoint.
|Temperature
|Controls the randomness of the output: high values lead to more diverse text
(creative/inventive), while low values produce more focused and deterministic
responses. The value typically ranges between 0 and 1 but can exceed this range
depending on the model. Note: Unless intentional, it is generally best to
adjust only one parameter; either temperature or Top-P/K, rather than multiple
simultaneously.
@@ -136,7 +136,9 @@ Below is a table detailing parameters specific to
individual language model APIs
|Determines how long to wait for a response before giving up.
|API Key/AccessToken
-| Unique access value used to authenticate and authorise access to the
endpoint service.
+| Unique access value used to authenticate and authorise access to the
endpoint service. The value
+is masked in the dialog and stored encrypted in the pipeline, unless it is a
variable such as
+`'${OPENAI_API_KEY}'`, which is stored as it stands so it can be resolved at
run time.
|Model Name
|Identifies which specific model to use.
@@ -148,7 +150,7 @@ Below is a table detailing parameters specific to
individual language model APIs
|The maximum number of retry attempts for failed API requests.
|Response Format
-|Defines the structure used for the model's responses. Note: While some APIs
support this option, not all models are compatible with it. Additionally, the
API may require explicitly instructing the model to produce JSON to ensure
proper functionality.
+|Defines the structure used for the model's responses. A value mentioning json
asks the model for JSON, any other value asks for text. Note: While some APIs
support this option, not all models are compatible with it. Additionally, the
API may require explicitly instructing the model to produce JSON to ensure
proper functionality.
|Seed
|Attempts to sample deterministically, ensuring consistent results for
repeated requests with the same seed, prompt, and parameters.
diff --git a/integration-tests/languagemodelchat/0001-ollama-chat.hpl
b/integration-tests/languagemodelchat/0001-ollama-chat.hpl
new file mode 100644
index 0000000000..d13267aee2
--- /dev/null
+++ b/integration-tests/languagemodelchat/0001-ollama-chat.hpl
@@ -0,0 +1,311 @@
+<?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>0001-ollama-chat</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Send a prompt to Ollama and check the answer and the token
counts come back</description>
+ <extended_description>A small model says what it likes, so the checks are
on the shape of the
+ output: no error, an answer, and token counts the transform could only
have read from a real
+ response.</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/09/11 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/09/11 20:00:00.000</modified_date>
+ </info>
+ <notepads/>
+ <order>
+ <hop>
+ <from>prompt</from>
+ <to>Language Model Chat</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Language Model Chat</from>
+ <to>check no error</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check no error</from>
+ <to>check answer</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check no error</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check answer</from>
+ <to>check token counts</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check answer</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check token counts</from>
+ <to>answer</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>check token counts</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>Language Model Chat</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>llm_</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>${OLLAMA_MODEL}</ollamaModelName>
+ <ollamaTemperature>0.1</ollamaTemperature>
+ <ollamaNumPredict>32</ollamaNumPredict>
+ <ollamaTimeout>300</ollamaTimeout>
+ <ollamaMaxRetries>3</ollamaMaxRetries>
+ <anthropicLogRequests>false</anthropicLogRequests>
+ <anthropicLogResponses>false</anthropicLogResponses>
+ <attributes/>
+ <GUI>
+ <xloc>256</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check no error</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>llm_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>432</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 token counts</send_true_to>
+ <send_false_to>Abort</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>llm_output</leftvalue>
+ <function>IS NOT NULL</function>
+ <rightvalue/>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>608</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check token counts</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>llm_total_token_count</leftvalue>
+ <function>></function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>0</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>784</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>Ollama answered</logmessage>
+ <fields>
+ <field>
+ <name>llm_model_name</name>
+ </field>
+ <field>
+ <name>llm_finish_reason</name>
+ </field>
+ <field>
+ <name>llm_total_token_count</name>
+ </field>
+ <field>
+ <name>llm_output</name>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>960</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>The Language Model Chat transform did not return a usable answer
from Ollama</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/>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/languagemodelchat/README.md
b/integration-tests/languagemodelchat/README.md
new file mode 100644
index 0000000000..16cf4a15ec
--- /dev/null
+++ b/integration-tests/languagemodelchat/README.md
@@ -0,0 +1,57 @@
+<!--
+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.
+-->
+
+# Language Model Chat integration tests
+
+The Language Model Chat transform talks to five providers, four of which need
+an API key. Ollama does not, so it is the one that can be tested here: a real
+model answers a real prompt over HTTP, which covers the request the transform
+builds, the call itself and the response it reads back.
+
+## Running
+
+The project carries a `disabled.txt`: `ollama/ollama` is a large image and the
+model still has to be pulled on top of it, which the standard suite should not
+pay for on every run. Run it explicitly:
+
+```
+./run-tests-docker.sh PROJECT_NAME=languagemodelchat INCLUDE_DISABLED=true
+```
+
+`docker/integration-tests/integration-tests-languagemodelchat.yaml` starts
+Ollama, pulls `smollm2:135m` (~270MB) and holds the test container back until
+the pull has finished. The endpoint and the model are set in
+`dev-env-config.json`, so a local Ollama can be used instead:
+
+```
+OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_MODEL=phi3
+```
+
+## What is checked
+
+A 135M parameter model writes what it likes, so the answer itself is not
+compared against anything. The checks are on the shape of the result:
+
+- `llm_finish_reason` is not `ERROR`. The transform catches every failure from
+ the model call and writes `ERROR` into this field rather than failing the
+ row, so without this check a pipeline that reached no model at all still ends
+ green.
+- `llm_output` is not null.
+- `llm_total_token_count` is greater than zero, which the transform can only
+ have read from a real response.
+
+Any of the three failing routes the row to `Abort`, which fails the pipeline.
diff --git a/integration-tests/languagemodelchat/dev-env-config.json
b/integration-tests/languagemodelchat/dev-env-config.json
new file mode 100644
index 0000000000..1f3645221b
--- /dev/null
+++ b/integration-tests/languagemodelchat/dev-env-config.json
@@ -0,0 +1,14 @@
+{
+ "variables": [
+ {
+ "name": "OLLAMA_BASE_URL",
+ "value": "http://ollama:11434",
+ "description": "Ollama endpoint on the docker integration-test network"
+ },
+ {
+ "name": "OLLAMA_MODEL",
+ "value": "smollm2:135m",
+ "description": "Model pulled by the ollama service in
docker/integration-tests/integration-tests-languagemodelchat.yaml"
+ }
+ ]
+}
diff --git a/integration-tests/languagemodelchat/disabled.txt
b/integration-tests/languagemodelchat/disabled.txt
new file mode 100644
index 0000000000..25ec6b4d5c
--- /dev/null
+++ b/integration-tests/languagemodelchat/disabled.txt
@@ -0,0 +1,21 @@
+#
+# 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.
+#
+# Needs the ollama image and a model pull, which the standard suite should not
pay for on every
+# run. Enable it explicitly:
+#
+# ./run-tests-docker.sh PROJECT_NAME=languagemodelchat INCLUDE_DISABLED=true
+#
diff --git a/integration-tests/languagemodelchat/hop-config.json
b/integration-tests/languagemodelchat/hop-config.json
new file mode 100644
index 0000000000..d9e1e6562e
--- /dev/null
+++ b/integration-tests/languagemodelchat/hop-config.json
@@ -0,0 +1,290 @@
+{
+ "variables": [
+ {
+ "name": "HOP_LENIENT_STRING_TO_NUMBER_CONVERSION",
+ "value": "N",
+ "description": "System wide flag to allow lenient string to number
conversion for backward compatibility. If this setting is set to \"Y\", an
string starting with digits will be converted successfully into a number.
(example: 192.168.1.1 will be converted into 192 or 192.168 or 192168 depending
on the decimal and grouping symbol). The default (N) will be to throw an error
if non-numeric symbols are found in the string."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_DB_IGNORE_TIMEZONE",
+ "value": "N",
+ "description": "System wide flag to ignore timezone while writing
date/timestamp value to the database."
+ },
+ {
+ "name": "HOP_LOG_SIZE_LIMIT",
+ "value": "0",
+ "description": "The log size limit for all pipelines and workflows that
don't have the \"log size limit\" property set in their respective properties."
+ },
+ {
+ "name": "HOP_EMPTY_STRING_DIFFERS_FROM_NULL",
+ "value": "N",
+ "description": "NULL vs Empty String. If this setting is set to Y, an
empty string and null are different. Otherwise they are not."
+ },
+ {
+ "name": "HOP_MAX_LOG_SIZE_IN_LINES",
+ "value": "0",
+ "description": "The maximum number of log lines that are kept internally
by Hop. Set to 0 to keep all rows (default)"
+ },
+ {
+ "name": "HOP_MAX_LOG_TIMEOUT_IN_MINUTES",
+ "value": "1440",
+ "description": "The maximum age (in minutes) of a log line while being
kept internally by Hop. Set to 0 to keep all rows indefinitely (default)"
+ },
+ {
+ "name": "HOP_MAX_WORKFLOW_TRACKER_SIZE",
+ "value": "5000",
+ "description": "The maximum number of workflow trackers kept in memory"
+ },
+ {
+ "name": "HOP_MAX_ACTIONS_LOGGED",
+ "value": "5000",
+ "description": "The maximum number of action results kept in memory for
logging purposes."
+ },
+ {
+ "name": "HOP_MAX_LOGGING_REGISTRY_SIZE",
+ "value": "10000",
+ "description": "The maximum number of logging registry entries kept in
memory for logging purposes."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_DELAY",
+ "value": "1000",
+ "description": "The hop log tab refresh delay."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_PERIOD",
+ "value": "1000",
+ "description": "The hop log tab refresh period."
+ },
+ {
+ "name": "HOP_PLUGIN_CLASSES",
+ "value": null,
+ "description": "A comma delimited list of classes to scan for plugin
annotations"
+ },
+ {
+ "name": "HOP_PLUGIN_PACKAGES",
+ "value": null,
+ "description": "A comma delimited list of packages to scan for plugin
annotations (warning: slow!!)"
+ },
+ {
+ "name": "HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT",
+ "value": "0",
+ "description": "The maximum number of transform performance snapshots to
keep in memory. Set to 0 to keep all snapshots indefinitely (default)"
+ },
+ {
+ "name": "HOP_ROWSET_GET_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an
alternative rowset get timeout (in ms). This only makes a difference for
extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_ROWSET_PUT_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an
alternative rowset put timeout (in ms). This only makes a difference for
extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_CORE_TRANSFORMS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the
alternative location of the hop-transforms.xml file. You can use this to
customize the list of available internal transforms outside of the codebase."
+ },
+ {
+ "name": "HOP_CORE_WORKFLOW_ACTIONS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the
alternative location of the hop-workflow-actions.xml file."
+ },
+ {
+ "name": "HOP_SERVER_OBJECT_TIMEOUT_MINUTES",
+ "value": "1440",
+ "description": "This project variable will set a time-out after which
waiting, completed or stopped pipelines and workflows will be automatically
cleaned up. The default value is 1440 (one day)."
+ },
+ {
+ "name": "HOP_PIPELINE_PAN_JVM_EXIT_CODE",
+ "value": null,
+ "description": "Set this variable to an integer that will be returned as
the Pan JVM exit code."
+ },
+ {
+ "name": "HOP_DISABLE_CONSOLE_LOGGING",
+ "value": "N",
+ "description": "Set this variable to Y to disable standard Hop logging
to the console. (stdout)"
+ },
+ {
+ "name": "HOP_REDIRECT_STDERR",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stderr to Hop
logging."
+ },
+ {
+ "name": "HOP_REDIRECT_STDOUT",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stdout to Hop
logging."
+ },
+ {
+ "name": "HOP_DEFAULT_NUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default number format"
+ },
+ {
+ "name": "HOP_DEFAULT_BIGNUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default bignumber format"
+ },
+ {
+ "name": "HOP_DEFAULT_INTEGER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default integer format"
+ },
+ {
+ "name": "HOP_DEFAULT_DATE_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default date format"
+ },
+ {
+ "name": "HOP_DEFAULT_TIMESTAMP_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative
default timestamp format"
+ },
+ {
+ "name": "HOP_DEFAULT_SERVLET_ENCODING",
+ "value": null,
+ "description": "Defines the default encoding for servlets, leave it
empty to use Java default encoding"
+ },
+ {
+ "name": "HOP_FAIL_ON_LOGGING_ERROR",
+ "value": "N",
+ "description": "Set this variable to Y when you want the
workflow/pipeline fail with an error when the related logging process (e.g. to
a database) fails."
+ },
+ {
+ "name": "HOP_AGGREGATION_MIN_NULL_IS_VALUED",
+ "value": "N",
+ "description": "Set this variable to Y to set the minimum to NULL if
NULL is within an aggregate. Otherwise by default NULL is ignored by the MIN
aggregate and MIN is set to the minimum value that is not NULL. See also the
variable HOP_AGGREGATION_ALL_NULLS_ARE_ZERO."
+ },
+ {
+ "name": "HOP_AGGREGATION_ALL_NULLS_ARE_ZERO",
+ "value": "N",
+ "description": "Set this variable to Y to return 0 when all values
within an aggregate are NULL. Otherwise by default a NULL is returned when all
values are NULL."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_TEXT_FILE_OUTPUT_APPEND_NO_HEADER",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for
the Text File Output transform. Setting this to Ywill add no header row at all
when the append option is enabled, regardless if the file is existing or not."
+ },
+ {
+ "name": "HOP_PASSWORD_ENCODER_PLUGIN",
+ "value": "Hop",
+ "description": "Specifies the password encoder plugin to use by ID (Hop
is the default)."
+ },
+ {
+ "name": "HOP_SYSTEM_HOSTNAME",
+ "value": null,
+ "description": "You can use this variable to speed up hostname lookup.
Hostname lookup is performed by Hop so that it is capable of logging the server
on which a workflow or pipeline is executed."
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPTORS",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptors for
Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPT_QUEUE_SIZE",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptQueueSize
for Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_RES_MAX_IDLE_TIME",
+ "value": null,
+ "description": "A variable to configure jetty option:
lowResourcesMaxIdleTime for Carte"
+ },
+ {
+ "name":
"HOP_COMPATIBILITY_MERGE_ROWS_USE_REFERENCE_STREAM_WHEN_IDENTICAL",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for
the Merge Rows (diff) transform. Setting this to Y will use the data from the
reference stream (instead of the comparison stream) in case the compared rows
are identical."
+ },
+ {
+ "name": "HOP_SPLIT_FIELDS_REMOVE_ENCLOSURE",
+ "value": "false",
+ "description": "Set this variable to false to preserve enclosure symbol
after splitting the string in the Split fields transform. Changing it to true
will remove first and last enclosure symbol from the resulting string chunks."
+ },
+ {
+ "name": "HOP_ALLOW_EMPTY_FIELD_NAMES_AND_TYPES",
+ "value": "false",
+ "description": "Set this variable to TRUE to allow your pipeline to pass
'null' fields and/or empty types."
+ },
+ {
+ "name": "HOP_GLOBAL_LOG_VARIABLES_CLEAR_ON_EXPORT",
+ "value": "false",
+ "description": "Set this variable to false to preserve global log
variables defined in pipeline / workflow Properties -> Log panel. Changing it
to true will clear it when export pipeline / workflow."
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_COUNT",
+ "value": "1024",
+ "description": "This project variable is used by the Text File Output
transform. It defines the max number of simultaneously open files within the
transform. The transform will close/reopen files as necessary to insure the max
is not exceeded"
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_LIFE",
+ "value": "0",
+ "description": "This project variable is used by the Text File Output
transform. It defines the max number of milliseconds between flushes of files
opened by the transform."
+ },
+ {
+ "name": "HOP_USE_NATIVE_FILE_DIALOG",
+ "value": "N",
+ "description": "Set this value to Y if you want to use the system file
open/save dialog when browsing files"
+ },
+ {
+ "name": "HOP_AUTO_CREATE_CONFIG",
+ "value": "Y",
+ "description": "Set this value to N if you don't want to automatically
create a hop configuration file (hop-config.json) when it's missing"
+ }
+ ],
+ "LocaleDefault": "en_BE",
+ "guiProperties": {
+ "FontFixedSize": "13",
+ "MaxUndo": "100",
+ "DarkMode": "Y",
+ "FontNoteSize": "13",
+ "ShowOSLook": "Y",
+ "FontFixedStyle": "0",
+ "FontNoteName": ".AppleSystemUIFont",
+ "FontFixedName": "Monospaced",
+ "FontGraphStyle": "0",
+ "FontDefaultSize": "13",
+ "GraphColorR": "255",
+ "FontGraphSize": "13",
+ "IconSize": "32",
+ "BackgroundColorB": "255",
+ "FontNoteStyle": "0",
+ "FontGraphName": ".AppleSystemUIFont",
+ "FontDefaultName": ".AppleSystemUIFont",
+ "GraphColorG": "255",
+ "UseGlobalFileBookmarks": "Y",
+ "FontDefaultStyle": "0",
+ "GraphColorB": "255",
+ "BackgroundColorR": "255",
+ "BackgroundColorG": "255",
+ "WorkflowDialogStyle": "RESIZE,MAX,MIN",
+ "LineWidth": "1",
+ "ContextDialogShowCategories": "Y"
+ },
+ "projectsConfig": {
+ "enabled": true,
+ "projectMandatory": true,
+ "environmentMandatory": false,
+ "defaultProject": "default",
+ "defaultEnvironment": null,
+ "standardParentProject": "default",
+ "standardProjectsFolder": null,
+ "projectConfigurations": [
+ {
+ "projectName": "default",
+ "projectHome": "${HOP_CONFIG_FOLDER}",
+ "configFilename": "project-config.json"
+ }
+ ],
+ "lifecycleEnvironments": [
+ {
+ "name": "dev",
+ "purpose": "Testing",
+ "projectName": "default",
+ "configurationFiles": [
+ "${PROJECT_HOME}/dev-env-config.json"
+ ]
+ }
+ ],
+ "projectLifecycles": []
+ }
+}
\ No newline at end of file
diff --git a/integration-tests/languagemodelchat/main-0001-ollama-chat.hwf
b/integration-tests/languagemodelchat/main-0001-ollama-chat.hwf
new file mode 100644
index 0000000000..548396a06a
--- /dev/null
+++ b/integration-tests/languagemodelchat/main-0001-ollama-chat.hwf
@@ -0,0 +1,89 @@
+<?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-0001-ollama-chat</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Run a prompt through the Language Model Chat transform against
Ollama</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/09/11 20:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/09/11 20:00:00.000</modified_date>
+ <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>0001-ollama-chat.hpl</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}/0001-ollama-chat.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>64</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>0001-ollama-chat.hpl</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads/>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/languagemodelchat/metadata/pipeline-run-configuration/local.json
b/integration-tests/languagemodelchat/metadata/pipeline-run-configuration/local.json
new file mode 100644
index 0000000000..d8e37459d8
--- /dev/null
+++
b/integration-tests/languagemodelchat/metadata/pipeline-run-configuration/local.json
@@ -0,0 +1,17 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "feedback_size": "50000",
+ "sample_size": "100",
+ "sample_type_in_gui": "Last",
+ "rowset_size": "10000",
+ "safe_mode": false,
+ "show_feedback": false,
+ "topo_sort": false,
+ "gather_metrics": false
+ }
+ },
+ "configurationVariables": [],
+ "name": "local",
+ "description": "Runs your pipelines locally with the standard local Hop
pipeline engine"
+}
diff --git
a/integration-tests/languagemodelchat/metadata/workflow-run-configuration/local.json
b/integration-tests/languagemodelchat/metadata/workflow-run-configuration/local.json
new file mode 100644
index 0000000000..ddb388679a
--- /dev/null
+++
b/integration-tests/languagemodelchat/metadata/workflow-run-configuration/local.json
@@ -0,0 +1,9 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "safe_mode": false
+ }
+ },
+ "name": "local",
+ "description": "Runs your workflows locally with the standard local Hop
workflow engine"
+}
diff --git a/integration-tests/languagemodelchat/project-config.json
b/integration-tests/languagemodelchat/project-config.json
new file mode 100644
index 0000000000..241753d668
--- /dev/null
+++ b/integration-tests/languagemodelchat/project-config.json
@@ -0,0 +1,15 @@
+{
+ "metadataBaseFolder": "${PROJECT_HOME}/metadata",
+ "unitTestsBasePath": "${PROJECT_HOME}",
+ "dataSetsCsvFolder": "${PROJECT_HOME}/datasets",
+ "enforcingExecutionInHome": true,
+ "config": {
+ "variables": [
+ {
+ "name": "HOP_LICENSE_HEADER_FILE",
+ "value": "${PROJECT_HOME}/../asf-header.txt",
+ "description": "This will automatically serialize the ASF license
header into pipelines and workflows in the integration test projects"
+ }
+ ]
+ }
+}
diff --git a/plugins/transforms/languagemodelchat/pom.xml
b/plugins/transforms/languagemodelchat/pom.xml
index d81edd2047..6500b16270 100644
--- a/plugins/transforms/languagemodelchat/pom.xml
+++ b/plugins/transforms/languagemodelchat/pom.xml
@@ -30,10 +30,7 @@
<name>Hop Plugins Transforms Language Model Chat</name>
<properties>
- <hop.pluginlib.exclude1>slf4j-tinylog</hop.pluginlib.exclude1>
- <hop.pluginlib.exclude2>tinylog-impl</hop.pluginlib.exclude2>
- <hop.pluginlib.exclude3>tinylog-api</hop.pluginlib.exclude3>
- <langchain4j.version>0.29.1</langchain4j.version>
+ <langchain4j.version>1.20.0</langchain4j.version>
</properties>
<dependencyManagement>
@@ -64,9 +61,16 @@
<artifactId>langchain4j-core</artifactId>
<version>${langchain4j.version}</version>
</dependency>
+ <!-- The Hugging Face inference API is called through this client
directly: the
+ langchain4j-hugging-face module is a beta artifact that upstream
deprecated for removal. -->
<dependency>
<groupId>dev.langchain4j</groupId>
- <artifactId>langchain4j-hugging-face</artifactId>
+ <artifactId>langchain4j-http-client</artifactId>
+ <version>${langchain4j.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>dev.langchain4j</groupId>
+ <artifactId>langchain4j-http-client-jdk</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
@@ -84,57 +88,6 @@
<artifactId>langchain4j-open-ai</artifactId>
<version>${langchain4j.version}</version>
</dependency>
- <dependency>
- <groupId>org.tinylog</groupId>
- <artifactId>slf4j-tinylog</artifactId>
- <version>2.6.2</version>
- </dependency>
- <dependency>
- <groupId>org.tinylog</groupId>
- <artifactId>tinylog-impl</artifactId>
- <version>2.6.2</version>
- </dependency>
- <dependency>
- <groupId>com.google.code.gson</groupId>
- <artifactId>gson</artifactId>
- <scope>provided</scope>
- </dependency>
- <dependency>
- <groupId>com.google.errorprone</groupId>
- <artifactId>error_prone_annotations</artifactId>
- <scope>provided</scope>
- </dependency>
- <dependency>
- <groupId>com.squareup.okhttp3</groupId>
- <artifactId>okhttp</artifactId>
- <scope>provided</scope>
- </dependency>
- <dependency>
- <groupId>com.squareup.okio</groupId>
- <artifactId>okio</artifactId>
- <scope>provided</scope>
- </dependency>
- <dependency>
- <groupId>com.squareup.okio</groupId>
- <artifactId>okio-jvm</artifactId>
- <scope>provided</scope>
- </dependency>
- <dependency>
- <groupId>org.jetbrains</groupId>
- <artifactId>annotations</artifactId>
- <scope>provided</scope>
- </dependency>
-
- <dependency>
- <groupId>org.jetbrains.kotlin</groupId>
- <artifactId>kotlin-stdlib</artifactId>
- <scope>provided</scope>
- </dependency>
- <dependency>
- <groupId>org.jetbrains.kotlin</groupId>
- <artifactId>kotlin-stdlib-jdk8</artifactId>
- <scope>provided</scope>
- </dependency>
</dependencies>
</project>
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointFactoryCreator.java
b/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointFactoryCreator.java
deleted file mode 100644
index 0db39bd924..0000000000
---
a/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointFactoryCreator.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * 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 dev.langchain4j.model.huggingface;
-
-import static dev.langchain4j.spi.ServiceHelper.loadFactories;
-
-import dev.langchain4j.model.huggingface.client.HuggingFaceClient;
-import dev.langchain4j.model.huggingface.spi.HuggingFaceClientFactory;
-
-class DedicatedEndpointFactoryCreator {
-
- private DedicatedEndpointFactoryCreator() {}
-
- static final HuggingFaceClientFactory FACTORY = factory();
-
- private static HuggingFaceClientFactory factory() {
- for (HuggingFaceClientFactory factory :
loadFactories(HuggingFaceClientFactory.class)) {
- return factory;
- }
- return new DefaultHuggingFaceClientFactory();
- }
-
- static class DefaultHuggingFaceClientFactory implements
HuggingFaceClientFactory {
-
- @Override
- public HuggingFaceClient create(Input input) {
- return new DedicatedEndpointHuggingFaceClient(
- input.apiKey(), input.modelId(), input.timeout());
- }
- }
-}
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointHuggingFaceApi.java
b/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointHuggingFaceApi.java
deleted file mode 100644
index f1f052b0e5..0000000000
---
a/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointHuggingFaceApi.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * 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 dev.langchain4j.model.huggingface;
-
-import dev.langchain4j.model.huggingface.client.EmbeddingRequest;
-import dev.langchain4j.model.huggingface.client.TextGenerationRequest;
-import dev.langchain4j.model.huggingface.client.TextGenerationResponse;
-import java.util.List;
-import retrofit2.Call;
-import retrofit2.http.Body;
-import retrofit2.http.Headers;
-import retrofit2.http.POST;
-
-interface DedicatedEndpointHuggingFaceApi {
-
- @POST("/")
- @Headers({"Content-Type: application/json"})
- Call<List<TextGenerationResponse>> generate(@Body TextGenerationRequest
request);
-
- @POST("/")
- @Headers({"Content-Type: application/json"})
- Call<List<float[]>> embed(@Body EmbeddingRequest request);
-}
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointHuggingFaceChatModel.java
b/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointHuggingFaceChatModel.java
deleted file mode 100644
index 02362490c9..0000000000
---
a/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointHuggingFaceChatModel.java
+++ /dev/null
@@ -1,178 +0,0 @@
-/*
- * 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 dev.langchain4j.model.huggingface;
-
-import static dev.langchain4j.internal.Utils.isNullOrBlank;
-import static
dev.langchain4j.model.huggingface.HuggingFaceModelName.TII_UAE_FALCON_7B_INSTRUCT;
-import static java.util.stream.Collectors.joining;
-
-import dev.langchain4j.data.message.AiMessage;
-import dev.langchain4j.data.message.ChatMessage;
-import dev.langchain4j.model.chat.ChatLanguageModel;
-import dev.langchain4j.model.huggingface.client.HuggingFaceClient;
-import dev.langchain4j.model.huggingface.client.Options;
-import dev.langchain4j.model.huggingface.client.Parameters;
-import dev.langchain4j.model.huggingface.client.TextGenerationRequest;
-import dev.langchain4j.model.huggingface.client.TextGenerationResponse;
-import dev.langchain4j.model.huggingface.spi.HuggingFaceClientFactory;
-import dev.langchain4j.model.output.Response;
-import java.time.Duration;
-import java.util.List;
-
-public class DedicatedEndpointHuggingFaceChatModel implements
ChatLanguageModel {
-
- private final HuggingFaceClient client;
- private final Double temperature;
- private final Integer maxNewTokens;
- private final Boolean returnFullText;
- private final Boolean waitForModel;
-
- public DedicatedEndpointHuggingFaceChatModel(
- String accessToken,
- String endpointUrl,
- Duration timeout,
- Double temperature,
- Integer maxNewTokens,
- Boolean returnFullText,
- Boolean waitForModel) {
- this(
- DedicatedEndpointHuggingFaceChatModel.builder()
- .accessToken(accessToken)
- .endpointUrl(endpointUrl)
- .timeout(timeout)
- .temperature(temperature)
- .maxNewTokens(maxNewTokens)
- .returnFullText(returnFullText)
- .waitForModel(waitForModel));
- }
-
- public DedicatedEndpointHuggingFaceChatModel(Builder builder) {
- this.client =
- DedicatedEndpointFactoryCreator.FACTORY.create(
- new HuggingFaceClientFactory.Input() {
- @Override
- public String apiKey() {
- return builder.accessToken;
- }
-
- @Override
- public String modelId() {
- return builder.endpointUrl;
- }
-
- @Override
- public Duration timeout() {
- return builder.timeout;
- }
- });
- this.temperature = builder.temperature;
- this.maxNewTokens = builder.maxNewTokens;
- this.returnFullText = builder.returnFullText;
- this.waitForModel = builder.waitForModel;
- }
-
- @Override
- public Response<AiMessage> generate(List<ChatMessage> messages) {
-
- TextGenerationRequest request =
- TextGenerationRequest.builder()
-
.inputs(messages.stream().map(ChatMessage::text).collect(joining("\n")))
- .parameters(
- Parameters.builder()
- .temperature(temperature)
- .maxNewTokens(maxNewTokens)
- .returnFullText(returnFullText)
- .build())
- .options(Options.builder().waitForModel(waitForModel).build())
- .build();
-
- TextGenerationResponse textGenerationResponse = client.chat(request);
-
- return
Response.from(AiMessage.from(textGenerationResponse.generatedText()));
- }
-
- public static Builder builder() {
- return new Builder();
- }
-
- public static final class Builder {
-
- private String accessToken;
- private String endpointUrl = TII_UAE_FALCON_7B_INSTRUCT;
- private Duration timeout = Duration.ofSeconds(15);
- private Double temperature;
- private Integer maxNewTokens;
- private Boolean returnFullText = false;
- private Boolean waitForModel = true;
-
- public Builder accessToken(String accessToken) {
- this.accessToken = accessToken;
- return this;
- }
-
- public Builder endpointUrl(String endpointUrl) {
- if (endpointUrl != null) {
- this.endpointUrl = endpointUrl;
- }
- return this;
- }
-
- public Builder timeout(Duration timeout) {
- if (timeout != null) {
- this.timeout = timeout;
- }
- return this;
- }
-
- public Builder temperature(Double temperature) {
- this.temperature = temperature;
- return this;
- }
-
- public Builder maxNewTokens(Integer maxNewTokens) {
- this.maxNewTokens = maxNewTokens;
- return this;
- }
-
- public Builder returnFullText(Boolean returnFullText) {
- if (returnFullText != null) {
- this.returnFullText = returnFullText;
- }
- return this;
- }
-
- public Builder waitForModel(Boolean waitForModel) {
- if (waitForModel != null) {
- this.waitForModel = waitForModel;
- }
- return this;
- }
-
- public DedicatedEndpointHuggingFaceChatModel build() {
- if (isNullOrBlank(accessToken)) {
- throw new IllegalArgumentException(
- "HuggingFace access token must be defined. It can be generated
here: https://huggingface.co/settings/tokens");
- }
- return new DedicatedEndpointHuggingFaceChatModel(this);
- }
- }
-
- public static DedicatedEndpointHuggingFaceChatModel withAccessToken(String
accessToken) {
- return builder().accessToken(accessToken).build();
- }
-}
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointHuggingFaceClient.java
b/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointHuggingFaceClient.java
deleted file mode 100644
index b694c72f52..0000000000
---
a/plugins/transforms/languagemodelchat/src/main/java/dev/langchain4j/model/huggingface/DedicatedEndpointHuggingFaceClient.java
+++ /dev/null
@@ -1,123 +0,0 @@
-/*
- * 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 dev.langchain4j.model.huggingface;
-
-import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
-import static dev.langchain4j.internal.ValidationUtils.ensureNotBlank;
-
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import dev.langchain4j.model.huggingface.client.EmbeddingRequest;
-import dev.langchain4j.model.huggingface.client.HuggingFaceClient;
-import dev.langchain4j.model.huggingface.client.TextGenerationRequest;
-import dev.langchain4j.model.huggingface.client.TextGenerationResponse;
-import java.io.IOException;
-import java.time.Duration;
-import java.util.List;
-import okhttp3.OkHttpClient;
-import org.apache.hop.core.exception.HopRuntimeException;
-import retrofit2.Response;
-import retrofit2.Retrofit;
-import retrofit2.converter.gson.GsonConverterFactory;
-
-class DedicatedEndpointHuggingFaceClient implements HuggingFaceClient {
-
- private final DedicatedEndpointHuggingFaceApi huggingFaceApi;
- private final String endpointUrl;
-
- DedicatedEndpointHuggingFaceClient(String apiKey, String endpointUrl,
Duration timeout) {
-
- this.endpointUrl = ensureNotBlank(endpointUrl, "endpointUrl");
- OkHttpClient okHttpClient =
- new OkHttpClient.Builder()
- .addInterceptor(new ApiKeyInsertingInterceptor(apiKey))
- .callTimeout(timeout)
- .connectTimeout(timeout)
- .readTimeout(timeout)
- .writeTimeout(timeout)
- .build();
-
- Gson gson = new
GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
-
- Retrofit retrofit =
- new Retrofit.Builder()
- .baseUrl(endpointUrl)
- .client(okHttpClient)
- .addConverterFactory(GsonConverterFactory.create(gson))
- .build();
-
- this.huggingFaceApi =
retrofit.create(DedicatedEndpointHuggingFaceApi.class);
- }
-
- @Override
- public TextGenerationResponse chat(TextGenerationRequest request) {
- return generate(request);
- }
-
- @Override
- public TextGenerationResponse generate(TextGenerationRequest request) {
- try {
- retrofit2.Response<List<TextGenerationResponse>> retrofitResponse =
- huggingFaceApi.generate(request).execute();
-
- if (retrofitResponse.isSuccessful()) {
- return toOneResponse(retrofitResponse);
- } else {
- throw toException(retrofitResponse);
- }
- } catch (IOException e) {
- throw new HopRuntimeException(e);
- }
- }
-
- private static TextGenerationResponse toOneResponse(
- Response<List<TextGenerationResponse>> retrofitResponse) {
- List<TextGenerationResponse> responses = retrofitResponse.body();
- if (responses != null && responses.size() == 1) {
- return responses.get(0);
- } else {
- throw new HopRuntimeException(
- "Expected only one generated_text, but was: "
- + (responses == null ? 0 : responses.size()));
- }
- }
-
- @Override
- public List<float[]> embed(EmbeddingRequest request) {
- try {
- retrofit2.Response<List<float[]>> retrofitResponse =
huggingFaceApi.embed(request).execute();
- if (retrofitResponse.isSuccessful()) {
- return retrofitResponse.body();
- } else {
- throw toException(retrofitResponse);
- }
- } catch (IOException e) {
- throw new HopRuntimeException(e);
- }
- }
-
- private static HopRuntimeException toException(retrofit2.Response<?>
response)
- throws IOException {
-
- int code = response.code();
- String body = response.errorBody().string();
-
- String errorMessage = String.format("status code: %s; body: %s", code,
body);
- return new HopRuntimeException(errorMessage);
- }
-}
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChat.java
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChat.java
index d30b38f6ad..65a6d5a1c4 100644
---
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChat.java
+++
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChat.java
@@ -30,9 +30,8 @@ import static org.apache.commons.lang3.StringUtils.trim;
import static org.apache.hop.core.util.Utils.isEmpty;
import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.ui.i18nUtil.i18n;
-import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
-import dev.langchain4j.model.output.Response;
+import dev.langchain4j.model.chat.response.ChatResponse;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@@ -255,7 +254,7 @@ public class LanguageModelChat extends
BaseTransform<LanguageModelChatMeta, Lang
} else {
Instant inferenceStart = now();
try {
- Response<AiMessage> ai = facade.generate(messageList);
+ ChatResponse ai = facade.chat(messageList);
inferenceTime = between(inferenceStart, now()).toMillis();
inputTokenCount =
ai.tokenUsage() == null || ai.tokenUsage().inputTokenCount() ==
null
@@ -271,9 +270,9 @@ public class LanguageModelChat extends
BaseTransform<LanguageModelChatMeta, Lang
: ai.tokenUsage().totalTokenCount().longValue();
finishReason = ai.finishReason() == null ? null :
ai.finishReason().name();
if (meta.isOutputChatJson()) {
- output = facade.messagesToOutput(messageList, ai.content().text());
+ output = facade.messagesToOutput(messageList, ai.aiMessage().text());
} else {
- output = ai.content().text();
+ output = ai.aiMessage().text();
}
} catch (Exception e) {
inferenceTime = between(inferenceStart, now()).toMillis();
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 553049ed11..01e9723eb4 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
@@ -21,13 +21,13 @@ import static
org.apache.hop.core.ICheckResult.TYPE_RESULT_ERROR;
import static org.apache.hop.core.ICheckResult.TYPE_RESULT_OK;
import static org.apache.hop.core.util.Utils.isEmpty;
import static org.apache.hop.i18n.BaseMessages.getString;
-import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.ANTHROPIC_CLAUDE_3_HAIKU_20240307;
-import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.ANTHROPIC_CLAUDE_3_OPUS_20240229;
-import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.HUGGING_FACE_LLAMA3_70B_INSTRUCT;
+import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.ANTHROPIC_CLAUDE_HAIKU_4_5;
+import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.ANTHROPIC_CLAUDE_SONNET_5;
+import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.HUGGING_FACE_LLAMA3_3_70B_INSTRUCT;
import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.MISTRAL_LARGE_LATEST;
-import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.OLLAMA_PHI3_3_8B;
-import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.OPENAI_GPT_4O;
-import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.OPENAI_GPT_4O_MINI;
+import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.OLLAMA_QWEN3;
+import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.OPENAI_GPT_5_6_LUNA;
+import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelChatModelName.OPENAI_GPT_6_ASTRA;
import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.ModelType.OPEN_AI;
import java.util.List;
@@ -74,9 +74,12 @@ public class LanguageModelChatMeta
// OpenAI
@HopMetadataProperty private String openAiBaseUrl =
"https://api.openai.com/v1";
- @HopMetadataProperty private String openAiApiKey = "OPENAI_API_KEY}";
+
+ @HopMetadataProperty(password = true)
+ private String openAiApiKey = "OPENAI_API_KEY";
+
@HopMetadataProperty private String openAiOrganizationId;
- @HopMetadataProperty private String openAiModelName =
OPENAI_GPT_4O.toString();
+ @HopMetadataProperty private String openAiModelName =
OPENAI_GPT_6_ASTRA.toString();
@HopMetadataProperty private Double openAiTemperature = 0.7;
@HopMetadataProperty private Double openAiTopP;
// TODO private List<String> openAiStop;
@@ -94,13 +97,15 @@ public class LanguageModelChatMeta
@HopMetadataProperty private Integer openAiProxyPort = 30000;
@HopMetadataProperty private boolean openAiLogRequests = false;
@HopMetadataProperty private boolean openAiLogResponses = false;
+
// TODO private Tokenizer openAiTokenizer = new OpenAiTokenizer();
// HuggingFace
- @HopMetadataProperty private String huggingFaceAccessToken =
"HF_ACCESS_TOKEN";
+ @HopMetadataProperty(password = true)
+ private String huggingFaceAccessToken = "HF_ACCESS_TOKEN";
@HopMetadataProperty
- private String huggingFaceModelId =
HUGGING_FACE_LLAMA3_70B_INSTRUCT.toString();
+ private String huggingFaceModelId =
HUGGING_FACE_LLAMA3_3_70B_INSTRUCT.toString();
@HopMetadataProperty private Integer huggingFaceTimeout = 15;
@HopMetadataProperty private Double huggingFaceTemperature;
@@ -110,7 +115,10 @@ public class LanguageModelChatMeta
// Mistral
@HopMetadataProperty private String mistralBaseUrl =
"https://api.mistral.ai/v1";
- @HopMetadataProperty private String mistralApiKey = "MISTRAL_API_KEY";
+
+ @HopMetadataProperty(password = true)
+ private String mistralApiKey = "MISTRAL_API_KEY";
+
@HopMetadataProperty private String mistralModelName =
MISTRAL_LARGE_LATEST.toString();
@HopMetadataProperty private Double mistralTemperature;
@HopMetadataProperty private Double mistralTopP;
@@ -125,7 +133,7 @@ public class LanguageModelChatMeta
// Ollama
@HopMetadataProperty private String ollamaImageEndpoint;
- @HopMetadataProperty private String ollamaModelName =
OLLAMA_PHI3_3_8B.toString();
+ @HopMetadataProperty private String ollamaModelName =
OLLAMA_QWEN3.toString();
@HopMetadataProperty private Double ollamaTemperature;
@HopMetadataProperty private Integer ollamaTopK;
@HopMetadataProperty private Double ollamaTopP;
@@ -140,11 +148,13 @@ public class LanguageModelChatMeta
// Anthropic
@HopMetadataProperty private String anthropicBaseUrl =
"https://api.anthropic.com/v1/";
- @HopMetadataProperty private String anthropicApiKey = "ANTHROPIC_API_KEY";
+
+ @HopMetadataProperty(password = true)
+ private String anthropicApiKey = "ANTHROPIC_API_KEY";
+
@HopMetadataProperty private String anthropicVersion = "2023-06-01";
- @HopMetadataProperty
- private String anthropicModelName =
ANTHROPIC_CLAUDE_3_OPUS_20240229.toString();
+ @HopMetadataProperty private String anthropicModelName =
ANTHROPIC_CLAUDE_SONNET_5.toString();
@HopMetadataProperty private Double anthropicTemperature;
@HopMetadataProperty private Double anthropicTopP;
@@ -174,7 +184,7 @@ public class LanguageModelChatMeta
openAiBaseUrl = "https://api.openai.com/v1";
openAiApiKey = "OPENAI_API_KEY";
- openAiModelName = OPENAI_GPT_4O_MINI.toString();
+ openAiModelName = OPENAI_GPT_5_6_LUNA.toString();
openAiResponseFormat = "text";
openAiTemperature = 0.7;
openAiTimeout = 60;
@@ -186,7 +196,7 @@ public class LanguageModelChatMeta
openAiLogResponses = false;
huggingFaceAccessToken = "HF_ACCESS_TOKEN";
- huggingFaceModelId = HUGGING_FACE_LLAMA3_70B_INSTRUCT.toString();
+ huggingFaceModelId = HUGGING_FACE_LLAMA3_3_70B_INSTRUCT.toString();
huggingFaceTimeout = 15;
huggingFaceReturnFullText = false;
huggingFaceWaitForModel = true;
@@ -201,14 +211,14 @@ public class LanguageModelChatMeta
mistralMaxRetries = 3;
ollamaImageEndpoint = "";
- ollamaModelName = OLLAMA_PHI3_3_8B.toString();
+ ollamaModelName = OLLAMA_QWEN3.toString();
ollamaTimeout = 60;
ollamaMaxRetries = 3;
anthropicBaseUrl = "https://api.anthropic.com/v1/";
anthropicApiKey = "ANTHROPIC_API_KEY";
anthropicVersion = "2023-06-01";
- anthropicModelName = ANTHROPIC_CLAUDE_3_HAIKU_20240307.toString();
+ anthropicModelName = ANTHROPIC_CLAUDE_HAIKU_4_5.toString();
anthropicMaxTokens = 1024;
anthropicTimeout = 15;
anthropicMaxRetries = 3;
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ChatMessages.java
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ChatMessages.java
new file mode 100644
index 0000000000..ec1df7fd5a
--- /dev/null
+++
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ChatMessages.java
@@ -0,0 +1,57 @@
+/*
+ * 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 java.util.stream.Collectors.joining;
+
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.TextContent;
+import dev.langchain4j.data.message.UserMessage;
+
+/**
+ * Reads the text out of a chat message. ChatMessage itself no longer exposes
text(): a user message
+ * can carry several contents (text and images), so the text parts are
collected here.
+ */
+public class ChatMessages {
+
+ private ChatMessages() {}
+
+ public static String text(ChatMessage message) {
+ if (message == null) {
+ return null;
+ }
+ return switch (message.type()) {
+ case SYSTEM -> ((SystemMessage) message).text();
+ case AI -> ((AiMessage) message).text();
+ case USER -> userText((UserMessage) message);
+ default -> null;
+ };
+ }
+
+ private static String userText(UserMessage message) {
+ if (message.hasSingleText()) {
+ return message.singleText();
+ }
+ return message.contents().stream()
+ .filter(TextContent.class::isInstance)
+ .map(content -> ((TextContent) content).text())
+ .collect(joining("\n"));
+ }
+}
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelChatModelName.java
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelChatModelName.java
index aedfb9b942..a9535b793c 100644
---
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelChatModelName.java
+++
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelChatModelName.java
@@ -19,34 +19,35 @@ package
org.apache.hop.pipeline.transforms.languagemodelchat.internals;
public enum LanguageModelChatModelName {
- // https://platform.openai.com/docs/models/continuous-model-upgrades
- OPENAI_GPT_4O("gpt-4o"),
- OPENAI_GPT_4O_MINI("gpt-4o-mini"),
- OPENAI_GPT_O1_MINI("o1-mini"),
- OPENAI_GPT_O1_PREVIEW("o1-preview"),
- OPENAI_GPT_4_TURBO("gpt-4-turbo"),
+ // https://platform.openai.com/docs/models
+ OPENAI_GPT_6_ASTRA("gpt-6-astra"),
+ OPENAI_GPT_5_6_TERRA("gpt-5.6-terra"),
+ OPENAI_GPT_5_6_LUNA("gpt-5.6-luna"),
+ OPENAI_GPT_5_6_SOL("gpt-5.6-sol"),
// https://docs.mistral.ai/getting-started/models/
- OPEN_MISTRAL_7B("open-mistral-7b"),
- OPEN_MIXTRAL_8X7B("open-mixtral-8x7b"),
- MISTRAL_SMALL_LATEST("mistral-small-latest"),
- MISTRAL_MEDIUM_LATEST("mistral-medium-latest"),
MISTRAL_LARGE_LATEST("mistral-large-latest"),
+ MISTRAL_MEDIUM_LATEST("mistral-medium-latest"),
+ MISTRAL_SMALL_LATEST("mistral-small-latest"),
+ MAGISTRAL_MEDIUM_2509("magistral-medium-2509"),
// https://ollama.com/library
- OLLAMA_LLAMA3_8B("llama3"),
- OLLAMA_LLAMA3_70B("llama3:70b"),
- OLLAMA_PHI3_3_8B("phi3"),
- OLLAMA_PHI3_14B("phi3:medium"),
-
- // https://ui.endpoints.huggingface.co/catalog
- HUGGING_FACE_LLAMA3_70B_INSTRUCT("meta-llama/Meta-Llama-3-70B-Instruct"),
+ OLLAMA_LLAMA3_3("llama3.3"),
+ OLLAMA_QWEN3("qwen3"),
+ OLLAMA_PHI4("phi4"),
+ OLLAMA_GEMMA3("gemma3"),
+ OLLAMA_DEEPSEEK_R1("deepseek-r1"),
+
+ // https://huggingface.co/models
+ HUGGING_FACE_QWEN3_8B("Qwen/Qwen3-8B"),
+ HUGGING_FACE_LLAMA3_3_70B_INSTRUCT("meta-llama/Llama-3.3-70B-Instruct"),
HUGGING_FACE_MISTRAL_7B_INSTRUCT("mistralai/Mistral-7B-Instruct-v0.3"),
+ HUGGING_FACE_GPT_OSS_20B("openai/gpt-oss-20b"),
- // https://docs.anthropic.com/en/docs/models-overview
- ANTHROPIC_CLAUDE_3_OPUS_20240229("claude-3-opus-20240229"),
- ANTHROPIC_CLAUDE_3_SONNET_20240229("claude-3-sonnet-20240229"),
- ANTHROPIC_CLAUDE_3_HAIKU_20240307("claude-3-haiku-20240307");
+ // https://docs.claude.com/en/docs/about-claude/models/overview
+ ANTHROPIC_CLAUDE_OPUS_5("claude-opus-5"),
+ ANTHROPIC_CLAUDE_SONNET_5("claude-sonnet-5"),
+ ANTHROPIC_CLAUDE_HAIKU_4_5("claude-haiku-4-5");
private final String stringValue;
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 202ba1a1d0..03aa5a4b23 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
@@ -21,10 +21,9 @@ import static com.squareup.moshi.Types.newParameterizedType;
import static dev.langchain4j.data.message.UserMessage.userMessage;
import static java.lang.String.format;
import static java.lang.System.getenv;
-import static java.net.Proxy.Type.HTTP;
import static java.time.Duration.ofSeconds;
+import static org.apache.commons.lang3.StringUtils.containsIgnoreCase;
import static org.apache.commons.lang3.StringUtils.isBlank;
-import static org.apache.commons.lang3.StringUtils.startsWithIgnoreCase;
import static org.apache.commons.lang3.StringUtils.trimToNull;
import static org.apache.commons.lang3.Validate.isTrue;
import static org.apache.commons.lang3.Validate.notNull;
@@ -32,23 +31,24 @@ import static
org.apache.hop.pipeline.transforms.languagemodelchat.internals.Mes
import com.squareup.moshi.JsonAdapter;
import com.squareup.moshi.Moshi;
-import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.http.client.jdk.JdkHttpClientBuilder;
import dev.langchain4j.model.anthropic.AnthropicChatModel;
-import dev.langchain4j.model.chat.ChatLanguageModel;
-import dev.langchain4j.model.huggingface.DedicatedEndpointHuggingFaceChatModel;
-import dev.langchain4j.model.huggingface.HuggingFaceChatModel;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.mistralai.MistralAiChatModel;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
-import dev.langchain4j.model.output.Response;
import java.net.InetSocketAddress;
-import java.net.Proxy;
+import java.net.ProxySelector;
+import java.net.http.HttpClient;
import java.util.ArrayList;
import java.util.List;
import org.apache.hop.core.exception.HopValueException;
import org.apache.hop.core.variables.IVariables;
import
org.apache.hop.pipeline.transforms.languagemodelchat.LanguageModelChatMeta;
+import
org.apache.hop.pipeline.transforms.languagemodelchat.internals.huggingface.HuggingFaceChatModel;
public class LanguageModelFacade {
@@ -57,7 +57,7 @@ public class LanguageModelFacade {
private final JsonAdapter<List<BaseMessage>> outputJsonAdapter;
private final LanguageModel lm;
private final IVariables variables;
- private ChatLanguageModel model;
+ private ChatModel model;
public LanguageModelFacade(IVariables variables, LanguageModelChatMeta meta)
{
this.variables = variables;
@@ -93,7 +93,7 @@ public class LanguageModelFacade {
return variables.resolve(trimToNull(param));
}
- private ChatLanguageModel createOpenAiModel() {
+ private ChatModel createOpenAiModel() {
String baseUrl = resolve(meta.getOpenAiBaseUrl());
String apiKey = trimToNull(resolve(meta.getOpenAiApiKey()));
@@ -124,7 +124,7 @@ public class LanguageModelFacade {
.maxTokens(maxTokens)
.presencePenalty(presencePenalty)
.frequencyPenalty(frequencyPenalty)
- .responseFormat(responseFormat)
+ .responseFormat(toResponseFormat(responseFormat))
.seed(seed)
.user(user)
.timeout(timeout == null ? null : ofSeconds(timeout))
@@ -133,19 +133,21 @@ public class LanguageModelFacade {
.logResponses(logResponses);
if (meta.isOpenAiUseProxy()) {
- builder.proxy(
- new Proxy(
- HTTP, new InetSocketAddress(meta.getOpenAiProxyHost(),
meta.getOpenAiProxyPort())));
+ builder.httpClientBuilder(
+ new JdkHttpClientBuilder()
+ .httpClientBuilder(
+ HttpClient.newBuilder()
+ .proxy(
+ ProxySelector.of(
+ new InetSocketAddress(
+ meta.getOpenAiProxyHost(),
meta.getOpenAiProxyPort())))));
}
return builder.build();
}
- private ChatLanguageModel createHuggingFaceModel() {
+ private ChatModel createHuggingFaceModel() {
String modelResource = resolve(meta.getHuggingFaceModelId());
- boolean dedicated =
- startsWithIgnoreCase(modelResource, "http://")
- || startsWithIgnoreCase(modelResource, "https://");
String accessToken = trimToNull(resolve(meta.getHuggingFaceAccessToken()));
accessToken =
@@ -157,30 +159,18 @@ public class LanguageModelFacade {
boolean returnFullText = meta.isHuggingFaceReturnFullText();
boolean waitForModel = meta.isHuggingFaceWaitForModel();
- if (dedicated) {
- return DedicatedEndpointHuggingFaceChatModel.builder()
- .accessToken(accessToken)
- .endpointUrl(modelResource)
- .timeout(timeout == null ? null : ofSeconds(timeout))
- .temperature(temperature)
- .maxNewTokens(maxNewTokens)
- .returnFullText(returnFullText)
- .waitForModel(waitForModel)
- .build();
- } else {
- return HuggingFaceChatModel.builder()
- .accessToken(accessToken)
- .modelId(modelResource)
- .timeout(timeout == null ? null : ofSeconds(timeout))
- .temperature(temperature)
- .maxNewTokens(maxNewTokens)
- .returnFullText(returnFullText)
- .waitForModel(waitForModel)
- .build();
- }
+ return HuggingFaceChatModel.builder()
+ .accessToken(accessToken)
+ .modelResource(modelResource)
+ .timeout(timeout == null ? null : ofSeconds(timeout))
+ .temperature(temperature)
+ .maxNewTokens(maxNewTokens)
+ .returnFullText(returnFullText)
+ .waitForModel(waitForModel)
+ .build();
}
- private ChatLanguageModel createOllamaModel() {
+ private ChatModel createOllamaModel() {
String modelName = resolve(meta.getOllamaModelName());
if (isBlank(modelName)) {
@@ -218,13 +208,13 @@ public class LanguageModelFacade {
.seed(seed)
.numPredict(numPredict)
.numCtx(numCtx)
- .format(format)
+ .responseFormat(toResponseFormat(format))
.timeout(timeout == null ? null : ofSeconds(timeout))
.maxRetries(maxRetries)
.build();
}
- private ChatLanguageModel createAnthropicModel() {
+ private ChatModel createAnthropicModel() {
String baseUrl = resolve(meta.getAnthropicBaseUrl());
String apiKey = trimToNull(resolve(meta.getAnthropicApiKey()));
@@ -257,7 +247,7 @@ public class LanguageModelFacade {
.build();
}
- private ChatLanguageModel createMistralModel() {
+ private ChatModel createMistralModel() {
String baseUrl = resolve(meta.getMistralBaseUrl());
String apiKey = trimToNull(resolve(meta.getMistralApiKey()));
apiKey = isBlank(apiKey) ? trimToNull(getenv(meta.getMistralApiKey())) :
apiKey;
@@ -283,7 +273,7 @@ public class LanguageModelFacade {
.maxTokens(maxTokens)
.safePrompt(safePrompt)
.randomSeed(randomSeed)
- .responseFormat(responseFormat)
+ .responseFormat(toResponseFormat(responseFormat))
.timeout(timeout == null ? null : ofSeconds(timeout))
.logRequests(logRequests)
.logResponses(logResponses)
@@ -291,6 +281,19 @@ public class LanguageModelFacade {
.build();
}
+ /**
+ * The providers take a response format object instead of the free format
string the dialogs ask
+ * for. Anything mentioning json asks for json, anything else for text. Open
AI does read a string
+ * as well, but only recognises "json_object" there and drops every other
value.
+ */
+ private static ResponseFormat toResponseFormat(String format) {
+ String value = trimToNull(format);
+ if (value == null) {
+ return null;
+ }
+ return containsIgnoreCase(value, "json") ? ResponseFormat.JSON :
ResponseFormat.TEXT;
+ }
+
@SuppressWarnings("java:S131")
public String messagesToOutput(List<ChatMessage> chat, String assistant) {
// TODO fetch these from the meta, hard code for now
@@ -300,7 +303,7 @@ public class LanguageModelFacade {
List<BaseMessage> messages = new ArrayList<>();
for (ChatMessage c : chat) {
- String text = c.text();
+ String text = ChatMessages.text(c);
switch (c.type()) {
case SYSTEM -> messages.add(new BaseMessage(systemRoleName, text));
case USER -> messages.add(new BaseMessage(userRoleName, text));
@@ -337,11 +340,11 @@ public class LanguageModelFacade {
return messageList;
}
- public Response<AiMessage> generate(List<ChatMessage> messages) {
- return model().generate(messages);
+ public ChatResponse chat(List<ChatMessage> messages) {
+ return model().chat(messages);
}
- public ChatLanguageModel model() {
+ public ChatModel model() {
if (model == null) {
createModel();
}
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/huggingface/HuggingFaceChatModel.java
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/huggingface/HuggingFaceChatModel.java
new file mode 100644
index 0000000000..737740131d
--- /dev/null
+++
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/huggingface/HuggingFaceChatModel.java
@@ -0,0 +1,139 @@
+/*
+ * 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.huggingface;
+
+import static java.util.stream.Collectors.joining;
+import static org.apache.commons.lang3.StringUtils.isBlank;
+
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import java.time.Duration;
+import java.util.Objects;
+import
org.apache.hop.pipeline.transforms.languagemodelchat.internals.ChatMessages;
+import
org.apache.hop.pipeline.transforms.languagemodelchat.internals.huggingface.HuggingFaceClient.Options;
+import
org.apache.hop.pipeline.transforms.languagemodelchat.internals.huggingface.HuggingFaceClient.Parameters;
+import
org.apache.hop.pipeline.transforms.languagemodelchat.internals.huggingface.HuggingFaceClient.TextGenerationRequest;
+
+/**
+ * A chat model on top of the Hugging Face text generation API. The
langchain4j Hugging Face module
+ * is a beta artifact that upstream deprecated for removal, so the API is
called here directly.
+ */
+public class HuggingFaceChatModel implements ChatModel {
+
+ private final HuggingFaceClient client;
+ private final Double temperature;
+ private final Integer maxNewTokens;
+ private final Boolean returnFullText;
+ private final Boolean waitForModel;
+
+ public HuggingFaceChatModel(Builder builder) {
+ if (isBlank(builder.accessToken)) {
+ throw new IllegalArgumentException(
+ "HuggingFace access token must be defined. It can be generated here:
https://huggingface.co/settings/tokens");
+ }
+ if (isBlank(builder.modelResource)) {
+ throw new IllegalArgumentException(
+ "HuggingFace model id or dedicated endpoint URL must be defined.");
+ }
+ this.client =
+ new HuggingFaceClient(builder.modelResource, builder.accessToken,
builder.timeout);
+ this.temperature = builder.temperature;
+ this.maxNewTokens = builder.maxNewTokens;
+ this.returnFullText = builder.returnFullText;
+ this.waitForModel = builder.waitForModel;
+ }
+
+ @Override
+ public ChatResponse doChat(ChatRequest chatRequest) {
+ String inputs =
+ chatRequest.messages().stream()
+ .map(ChatMessages::text)
+ .filter(Objects::nonNull)
+ .collect(joining("\n"));
+
+ TextGenerationRequest request =
+ new TextGenerationRequest(
+ inputs,
+ new Parameters(temperature, maxNewTokens, returnFullText),
+ new Options(waitForModel));
+
+ return
ChatResponse.builder().aiMessage(AiMessage.from(client.generate(request))).build();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String accessToken;
+ private String modelResource;
+ private Duration timeout = Duration.ofSeconds(15);
+ private Double temperature;
+ private Integer maxNewTokens;
+ private Boolean returnFullText = false;
+ private Boolean waitForModel = true;
+
+ public Builder accessToken(String accessToken) {
+ this.accessToken = accessToken;
+ return this;
+ }
+
+ /** A Hugging Face model id, or the URL of a dedicated inference endpoint.
*/
+ public Builder modelResource(String modelResource) {
+ this.modelResource = modelResource;
+ return this;
+ }
+
+ public Builder timeout(Duration timeout) {
+ if (timeout != null) {
+ this.timeout = timeout;
+ }
+ return this;
+ }
+
+ public Builder temperature(Double temperature) {
+ this.temperature = temperature;
+ return this;
+ }
+
+ public Builder maxNewTokens(Integer maxNewTokens) {
+ this.maxNewTokens = maxNewTokens;
+ return this;
+ }
+
+ public Builder returnFullText(Boolean returnFullText) {
+ if (returnFullText != null) {
+ this.returnFullText = returnFullText;
+ }
+ return this;
+ }
+
+ public Builder waitForModel(Boolean waitForModel) {
+ if (waitForModel != null) {
+ this.waitForModel = waitForModel;
+ }
+ return this;
+ }
+
+ public HuggingFaceChatModel build() {
+ return new HuggingFaceChatModel(this);
+ }
+ }
+}
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/huggingface/HuggingFaceClient.java
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/huggingface/HuggingFaceClient.java
new file mode 100644
index 0000000000..29ef4301b4
--- /dev/null
+++
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/huggingface/HuggingFaceClient.java
@@ -0,0 +1,146 @@
+/*
+ * 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.huggingface;
+
+import static com.squareup.moshi.Types.newParameterizedType;
+import static dev.langchain4j.http.client.HttpMethod.POST;
+import static org.apache.commons.lang3.StringUtils.startsWithIgnoreCase;
+
+import com.squareup.moshi.Json;
+import com.squareup.moshi.JsonAdapter;
+import com.squareup.moshi.Moshi;
+import dev.langchain4j.exception.HttpException;
+import dev.langchain4j.http.client.HttpClient;
+import dev.langchain4j.http.client.HttpClientBuilderLoader;
+import dev.langchain4j.http.client.HttpRequest;
+import dev.langchain4j.http.client.SuccessfulHttpResponse;
+import java.time.Duration;
+import java.util.List;
+import org.apache.hop.core.exception.HopRuntimeException;
+
+/**
+ * Calls the Hugging Face text generation API. A model id is sent to the
Hugging Face inference
+ * router, an http(s) model resource is taken to be a dedicated inference
endpoint and is called as
+ * it stands.
+ */
+public class HuggingFaceClient {
+
+ private static final String ROUTER_URL =
"https://router.huggingface.co/hf-inference/models/";
+
+ private final HttpClient httpClient;
+ private final JsonAdapter<TextGenerationRequest> requestAdapter;
+ private final JsonAdapter<List<TextGenerationResponse>> responseAdapter;
+ private final String url;
+ private final String accessToken;
+
+ public HuggingFaceClient(String modelResource, String accessToken, Duration
timeout) {
+ this.httpClient =
+ HttpClientBuilderLoader.loadHttpClientBuilder()
+ .connectTimeout(timeout)
+ .readTimeout(timeout)
+ .build();
+ this.url = isDedicatedEndpoint(modelResource) ? modelResource : ROUTER_URL
+ modelResource;
+ this.accessToken = accessToken;
+
+ Moshi moshi = new Moshi.Builder().build();
+ this.requestAdapter = moshi.adapter(TextGenerationRequest.class);
+ this.responseAdapter =
+ moshi.adapter(newParameterizedType(List.class,
TextGenerationResponse.class));
+ }
+
+ public static boolean isDedicatedEndpoint(String modelResource) {
+ return startsWithIgnoreCase(modelResource, "http://")
+ || startsWithIgnoreCase(modelResource, "https://");
+ }
+
+ public String generate(TextGenerationRequest request) {
+ HttpRequest httpRequest =
+ HttpRequest.builder()
+ .method(POST)
+ .url(url)
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Authorization", "Bearer " + accessToken)
+ .body(requestAdapter.toJson(request))
+ .build();
+
+ SuccessfulHttpResponse httpResponse;
+ try {
+ httpResponse = httpClient.execute(httpRequest);
+ } catch (HttpException e) {
+ throw new HopRuntimeException(
+ "status code: " + e.statusCode() + "; body: " + e.getMessage(), e);
+ }
+
+ List<TextGenerationResponse> responses;
+ try {
+ responses = responseAdapter.fromJson(httpResponse.body());
+ } catch (Exception e) {
+ throw new HopRuntimeException("Could not read the Hugging Face
response", e);
+ }
+
+ if (responses == null || responses.size() != 1) {
+ throw new HopRuntimeException(
+ "Expected only one generated_text, but was: "
+ + (responses == null ? 0 : responses.size()));
+ }
+
+ return responses.get(0).generatedText;
+ }
+
+ public static class TextGenerationRequest {
+ private String inputs;
+ private Parameters parameters;
+ private Options options;
+
+ public TextGenerationRequest(String inputs, Parameters parameters, Options
options) {
+ this.inputs = inputs;
+ this.parameters = parameters;
+ this.options = options;
+ }
+ }
+
+ public static class Parameters {
+ private Double temperature;
+
+ @Json(name = "max_new_tokens")
+ private Integer maxNewTokens;
+
+ @Json(name = "return_full_text")
+ private Boolean returnFullText;
+
+ public Parameters(Double temperature, Integer maxNewTokens, Boolean
returnFullText) {
+ this.temperature = temperature;
+ this.maxNewTokens = maxNewTokens;
+ this.returnFullText = returnFullText;
+ }
+ }
+
+ public static class Options {
+ @Json(name = "wait_for_model")
+ private Boolean waitForModel;
+
+ public Options(Boolean waitForModel) {
+ this.waitForModel = waitForModel;
+ }
+ }
+
+ public static class TextGenerationResponse {
+ @Json(name = "generated_text")
+ private String generatedText;
+ }
+}
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/AbstractModelComposite.java
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/AbstractModelComposite.java
index 29f77179d6..e266747943 100644
---
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/AbstractModelComposite.java
+++
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/AbstractModelComposite.java
@@ -39,6 +39,7 @@ import
org.apache.hop.pipeline.transforms.languagemodelchat.LanguageModelChatMet
import
org.apache.hop.pipeline.transforms.languagemodelchat.internals.ModelType;
import
org.apache.hop.pipeline.transforms.languagemodelchat.internals.ui.CompositeParameters;
import
org.apache.hop.pipeline.transforms.languagemodelchat.internals.ui.FormDataBuilder;
+import org.apache.hop.ui.core.widget.PasswordTextVar;
import org.apache.hop.ui.core.widget.TextVar;
import org.eclipse.swt.custom.CCombo;
import org.eclipse.swt.layout.FormAttachment;
@@ -87,6 +88,11 @@ public abstract class AbstractModelComposite implements
IModelComposite {
return new TextVar(parameters.variables(), composite, SINGLE | LEFT |
BORDER);
}
+ /** Api keys and access tokens are masked, the way every other secret in Hop
is. */
+ protected TextVar createPasswordTextVar() {
+ return new PasswordTextVar(parameters.variables(), composite, SINGLE |
LEFT | BORDER);
+ }
+
protected Button createButton() {
return new Button(composite, CHECK);
}
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/AnthropicComposite.java
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/AnthropicComposite.java
index 7b3b7e9741..14291be740 100644
---
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/AnthropicComposite.java
+++
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/AnthropicComposite.java
@@ -65,7 +65,7 @@ public class AnthropicComposite extends
AbstractModelComposite {
// API Key
apiKeyLabel = createLabel();
- apiKeyInput = createTextVar();
+ apiKeyInput = createPasswordTextVar();
prepare("ApiKey", apiKeyLabel, apiKeyInput);
// Version
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/HuggingFaceComposite.java
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/HuggingFaceComposite.java
index 8da752e080..67cd782749 100644
---
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/HuggingFaceComposite.java
+++
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/HuggingFaceComposite.java
@@ -47,7 +47,7 @@ public class HuggingFaceComposite extends
AbstractModelComposite {
// Access Token
accessTokenLabel = createLabel();
- accessTokenInput = createTextVar();
+ accessTokenInput = createPasswordTextVar();
prepare("AccessToken", accessTokenLabel, accessTokenInput);
// Temperature
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/MistralComposite.java
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/MistralComposite.java
index 2036f750da..a941630318 100644
---
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/MistralComposite.java
+++
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/MistralComposite.java
@@ -62,7 +62,7 @@ public class MistralComposite extends AbstractModelComposite {
// API Key
apiKeyLabel = createLabel();
- apiKeyInput = createTextVar();
+ apiKeyInput = createPasswordTextVar();
prepare("ApiKey", apiKeyLabel, apiKeyInput);
// Model Name
diff --git
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OpenAiComposite.java
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OpenAiComposite.java
index eb4c954ee0..7dae1c6d69 100644
---
a/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OpenAiComposite.java
+++
b/plugins/transforms/languagemodelchat/src/main/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/ui/models/OpenAiComposite.java
@@ -76,7 +76,7 @@ public class OpenAiComposite extends AbstractModelComposite {
// API Key
apiKeyLabel = createLabel();
- apiKeyInput = createTextVar();
+ apiKeyInput = createPasswordTextVar();
prepare("ApiKey", apiKeyLabel, apiKeyInput);
// Model Name
diff --git
a/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/fact-checking/bespoke-minicheck.hpl
b/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/fact-checking/bespoke-minicheck.hpl
index 4a121e9d31..dd01e56004 100644
---
a/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/fact-checking/bespoke-minicheck.hpl
+++
b/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/fact-checking/bespoke-minicheck.hpl
@@ -429,7 +429,7 @@ Claim: ${claim}</input_text>
<openAiLogRequests>false</openAiLogRequests>
<openAiLogResponses>false</openAiLogResponses>
<huggingFaceAccessToken>${HF_TOKEN}</huggingFaceAccessToken>
- <huggingFaceModelId>meta-llama/Llama-3.1-70B-Instruct</huggingFaceModelId>
+ <huggingFaceModelId>meta-llama/Llama-3.3-70B-Instruct</huggingFaceModelId>
<huggingFaceTemperature>0.7</huggingFaceTemperature>
<huggingFaceMaxNewTokens>50</huggingFaceMaxNewTokens>
<huggingFaceReturnFullText>false</huggingFaceReturnFullText>
@@ -465,7 +465,7 @@ Claim: ${claim}</input_text>
<parallelism>1</parallelism>
<openAiBaseUrl>https://api.openai.com/v1</openAiBaseUrl>
<openAiApiKey>${OPENAI_API_KEY}</openAiApiKey>
- <openAiModelName>gpt-4o-mini</openAiModelName>
+ <openAiModelName>gpt-5.6-luna</openAiModelName>
<openAiTemperature>0.7</openAiTemperature>
<openAiMaxTokens>50</openAiMaxTokens>
<openAiResponseFormat>text</openAiResponseFormat>
diff --git
a/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/anthropic.hpl
b/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/anthropic.hpl
index 7c4d7de5b7..c5977ed17a 100644
---
a/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/anthropic.hpl
+++
b/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/anthropic.hpl
@@ -259,7 +259,7 @@ limitations under the License.
<anthropicBaseUrl>https://api.anthropic.com/v1/</anthropicBaseUrl>
<anthropicApiKey>${ANTHROPIC_API_KEY}</anthropicApiKey>
<anthropicVersion>2023-06-01</anthropicVersion>
- <anthropicModelName>claude-3-5-sonnet-20240620</anthropicModelName>
+ <anthropicModelName>claude-sonnet-5</anthropicModelName>
<anthropicMaxTokens>1024</anthropicMaxTokens>
<anthropicTimeout>15</anthropicTimeout>
<anthropicMaxRetries>3</anthropicMaxRetries>
diff --git
a/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/huggingface.hpl
b/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/huggingface.hpl
index 08de7ee2b1..9daac39521 100644
---
a/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/huggingface.hpl
+++
b/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/huggingface.hpl
@@ -82,7 +82,7 @@ limitations under the License.
<openAiLogRequests>false</openAiLogRequests>
<openAiLogResponses>false</openAiLogResponses>
<huggingFaceAccessToken>${HF_TOKEN}</huggingFaceAccessToken>
- <huggingFaceModelId>meta-llama/Llama-3.1-70B-Instruct</huggingFaceModelId>
+ <huggingFaceModelId>meta-llama/Llama-3.3-70B-Instruct</huggingFaceModelId>
<huggingFaceTemperature>0.1</huggingFaceTemperature>
<huggingFaceMaxNewTokens>50</huggingFaceMaxNewTokens>
<huggingFaceReturnFullText>false</huggingFaceReturnFullText>
diff --git
a/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/openai.hpl
b/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/openai.hpl
index 549ded8867..b9c3b79c84 100644
---
a/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/openai.hpl
+++
b/plugins/transforms/languagemodelchat/src/main/samples/transforms/languagemodelchat/zero-shot/openai.hpl
@@ -272,7 +272,7 @@
https://upload.wikimedia.org/wikipedia/commons/3/3d/Fesoj_-_Papilio_machaon_%28b
<parallelism>1</parallelism>
<openAiBaseUrl>https://api.openai.com/v1</openAiBaseUrl>
<openAiApiKey>${OPENAI_API_KEY}</openAiApiKey>
- <openAiModelName>gpt-4-turbo</openAiModelName>
+ <openAiModelName>gpt-5.6-luna</openAiModelName>
<openAiTimeout>60</openAiTimeout>
<openAiMaxRetries>3</openAiMaxRetries>
<openAiUseProxy>false</openAiUseProxy>
diff --git
a/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChatMetaSecretsTest.java
b/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChatMetaSecretsTest.java
new file mode 100644
index 0000000000..e54d938cc1
--- /dev/null
+++
b/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/LanguageModelChatMetaSecretsTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.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.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.w3c.dom.Node;
+
+/** Api keys and access tokens are stored the way Hop stores every other
secret. */
+class LanguageModelChatMetaSecretsTest {
+
+ @RegisterExtension
+ static RestoreHopEngineEnvironmentExtension env = new
RestoreHopEngineEnvironmentExtension();
+
+ @BeforeAll
+ static void init() throws Exception {
+ HopClientEnvironment.init();
+ }
+
+ @Test
+ void keysAreNotWrittenInTheClear() throws Exception {
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ meta.setOpenAiApiKey("sk-open-ai-secret");
+ meta.setAnthropicApiKey("sk-anthropic-secret");
+ meta.setMistralApiKey("sk-mistral-secret");
+ meta.setHuggingFaceAccessToken("hf-secret");
+
+ String xml = meta.getXml();
+
+ assertFalse(xml.contains("sk-open-ai-secret"), xml);
+ assertFalse(xml.contains("sk-anthropic-secret"), xml);
+ assertFalse(xml.contains("sk-mistral-secret"), xml);
+ assertFalse(xml.contains("hf-secret"), xml);
+ assertTrue(xml.contains("<openAiApiKey>Encrypted "), xml);
+
+ LanguageModelChatMeta back = fromXml(xml);
+ assertEquals("sk-open-ai-secret", back.getOpenAiApiKey());
+ assertEquals("sk-anthropic-secret", back.getAnthropicApiKey());
+ assertEquals("sk-mistral-secret", back.getMistralApiKey());
+ assertEquals("hf-secret", back.getHuggingFaceAccessToken());
+ }
+
+ @Test
+ void variablesStayVariables() throws Exception {
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ meta.setOpenAiApiKey("${OPENAI_API_KEY}");
+ meta.setAnthropicApiKey("${ANTHROPIC_API_KEY}");
+ meta.setMistralApiKey("${MISTRAL_API_KEY}");
+ meta.setHuggingFaceAccessToken("${HF_ACCESS_TOKEN}");
+
+ String xml = meta.getXml();
+ assertTrue(xml.contains("<openAiApiKey>${OPENAI_API_KEY}</openAiApiKey>"),
xml);
+
+ LanguageModelChatMeta back = fromXml(xml);
+ assertEquals("${OPENAI_API_KEY}", back.getOpenAiApiKey());
+ assertEquals("${ANTHROPIC_API_KEY}", back.getAnthropicApiKey());
+ assertEquals("${MISTRAL_API_KEY}", back.getMistralApiKey());
+ assertEquals("${HF_ACCESS_TOKEN}", back.getHuggingFaceAccessToken());
+ }
+
+ /** Pipelines written before the fields became passwords hold their value in
the clear. */
+ @Test
+ void plainTextKeysFromOlderPipelinesStillLoad() throws Exception {
+ LanguageModelChatMeta back =
+ fromXml(
+ "<openAiApiKey>sk-written-by-an-older-hop</openAiApiKey>"
+ + "<mistralApiKey>${MISTRAL_API_KEY}</mistralApiKey>"
+ +
"<huggingFaceAccessToken>HF_ACCESS_TOKEN</huggingFaceAccessToken>");
+
+ assertEquals("sk-written-by-an-older-hop", back.getOpenAiApiKey());
+ assertEquals("${MISTRAL_API_KEY}", back.getMistralApiKey());
+ assertEquals("HF_ACCESS_TOKEN", back.getHuggingFaceAccessToken());
+ }
+
+ /** The field default disagreed with setDefault() and shipped a stray brace,
see issue #8336. */
+ @Test
+ void openAiApiKeyDefaultHasNoStrayBrace() {
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ assertEquals("OPENAI_API_KEY", meta.getOpenAiApiKey());
+
+ meta.setDefault();
+ assertEquals("OPENAI_API_KEY", meta.getOpenAiApiKey());
+ }
+
+ 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
new file mode 100644
index 0000000000..25f8757a18
--- /dev/null
+++
b/plugins/transforms/languagemodelchat/src/test/java/org/apache/hop/pipeline/transforms/languagemodelchat/internals/LanguageModelFacadeChatTest.java
@@ -0,0 +1,211 @@
+/*
+ * 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 java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.net.httpserver.HttpServer;
+import dev.langchain4j.model.chat.response.ChatResponse;
+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.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import
org.apache.hop.pipeline.transforms.languagemodelchat.LanguageModelChatMeta;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * Drives the providers against a stub http server, so the request
serialization, the http call and
+ * the response parsing are all covered without reaching out to a provider.
+ */
+class LanguageModelFacadeChatTest {
+
+ @RegisterExtension
+ static RestoreHopEngineEnvironmentExtension env = new
RestoreHopEngineEnvironmentExtension();
+
+ private final IVariables variables = new Variables();
+ 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);
+ baseUrl = "http://localhost:" + server.getAddress().getPort();
+ server.start();
+ }
+
+ @AfterEach
+ void stopServer() {
+ server.stop(0);
+ }
+
+ /** Provider payloads are pretty printed, so requests are matched without
their whitespace. */
+ private String compactRequestBody() {
+ return lastRequestBody.get().replaceAll("\\s+", "");
+ }
+
+ private void respondWith(String path, String body) {
+ server.createContext(
+ path,
+ exchange -> {
+ try (InputStream in = exchange.getRequestBody()) {
+ lastRequestBody.set(new String(in.readAllBytes(), UTF_8));
+ }
+ byte[] bytes = body.getBytes(UTF_8);
+ exchange.getResponseHeaders().add("Content-Type",
"application/json");
+ exchange.sendResponseHeaders(200, bytes.length);
+ exchange.getResponseBody().write(bytes);
+ exchange.close();
+ });
+ }
+
+ @Test
+ void openAi() throws Exception {
+ respondWith(
+ "/v1/chat/completions",
+ """
+
{"id":"chat-1","object":"chat.completion","created":1,"model":"gpt-test",
+
"choices":[{"index":0,"message":{"role":"assistant","content":"four"},"finish_reason":"stop"}],
+ "usage":{"prompt_tokens":11,"completion_tokens":22,"total_tokens":33}}
+ """);
+
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ meta.setModelType(ModelType.OPEN_AI.code());
+ meta.setOpenAiBaseUrl(baseUrl + "/v1");
+ meta.setOpenAiApiKey("test-key");
+ meta.setOpenAiModelName("gpt-test");
+ meta.setOpenAiResponseFormat("json");
+
+ ChatResponse response = chat(meta, "two plus two");
+
+ assertEquals("four", response.aiMessage().text());
+ assertEquals(11, response.tokenUsage().inputTokenCount());
+ assertEquals(22, response.tokenUsage().outputTokenCount());
+ assertEquals(33, response.tokenUsage().totalTokenCount());
+ assertTrue(compactRequestBody().contains("twoplustwo"),
lastRequestBody.get());
+ assertTrue(
+
compactRequestBody().contains("\"response_format\":{\"type\":\"json_object\"}"),
+ lastRequestBody.get());
+ }
+
+ @Test
+ void ollama() throws Exception {
+ respondWith(
+ "/api/chat",
+ """
+ {"model":"phi3","created_at":"2026-09-11T00:00:00Z",
+ "message":{"role":"assistant","content":"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("phi3");
+ meta.setOllamaFormat("json");
+
+ ChatResponse response = chat(meta, "two plus two");
+
+ assertEquals("four", response.aiMessage().text());
+ assertEquals(11, response.tokenUsage().inputTokenCount());
+ assertTrue(compactRequestBody().contains("\"format\":\"json\""),
lastRequestBody.get());
+ }
+
+ @Test
+ void anthropic() throws Exception {
+ respondWith(
+ "/v1/messages",
+ """
+
{"id":"msg-1","type":"message","role":"assistant","model":"claude-test",
+ "content":[{"type":"text","text":"four"}],
+
"stop_reason":"end_turn","usage":{"input_tokens":11,"output_tokens":22}}
+ """);
+
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ meta.setModelType(ModelType.ANTHROPIC.code());
+ meta.setAnthropicBaseUrl(baseUrl + "/v1/");
+ meta.setAnthropicApiKey("test-key");
+ meta.setAnthropicModelName("claude-test");
+
+ ChatResponse response = chat(meta, "two plus two");
+
+ assertEquals("four", response.aiMessage().text());
+ assertEquals(11, response.tokenUsage().inputTokenCount());
+ assertEquals(22, response.tokenUsage().outputTokenCount());
+ }
+
+ @Test
+ void mistral() throws Exception {
+ respondWith(
+ "/v1/chat/completions",
+ """
+
{"id":"chat-1","object":"chat.completion","created":1,"model":"mistral-test",
+
"choices":[{"index":0,"message":{"role":"assistant","content":"four"},"finish_reason":"stop"}],
+ "usage":{"prompt_tokens":11,"completion_tokens":22,"total_tokens":33}}
+ """);
+
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ meta.setModelType(ModelType.MISTRAL.code());
+ meta.setMistralBaseUrl(baseUrl + "/v1/");
+ meta.setMistralApiKey("test-key");
+ meta.setMistralModelName("mistral-test");
+ meta.setMistralResponseFormat("json_object");
+
+ ChatResponse response = chat(meta, "two plus two");
+
+ assertEquals("four", response.aiMessage().text());
+ assertEquals(33, response.tokenUsage().totalTokenCount());
+ assertTrue(
+
compactRequestBody().contains("\"response_format\":{\"type\":\"json_object\"}"),
+ lastRequestBody.get());
+ }
+
+ @Test
+ void huggingFaceDedicatedEndpoint() throws Exception {
+ respondWith("/", """
+ [{"generated_text":"four"}]
+ """);
+
+ LanguageModelChatMeta meta = new LanguageModelChatMeta();
+ meta.setModelType(ModelType.HUGGING_FACE.code());
+ meta.setHuggingFaceModelId(baseUrl + "/");
+ meta.setHuggingFaceAccessToken("test-token");
+ meta.setHuggingFaceMaxNewTokens(10);
+
+ ChatResponse response = chat(meta, "two plus two");
+
+ assertEquals("four", response.aiMessage().text());
+ assertTrue(compactRequestBody().contains("\"max_new_tokens\":10"),
lastRequestBody.get());
+ assertTrue(compactRequestBody().contains("\"wait_for_model\":true"),
lastRequestBody.get());
+ }
+
+ private ChatResponse chat(LanguageModelChatMeta meta, String message) throws
Exception {
+ LanguageModelFacade facade = new LanguageModelFacade(variables, meta);
+ return
facade.chat(List.of(dev.langchain4j.data.message.UserMessage.userMessage(message)));
+ }
+}