devmadhuu commented on code in PR #9915:
URL: https://github.com/apache/ozone/pull/9915#discussion_r3224534208
##########
hadoop-ozone/recon/pom.xml:
##########
@@ -62,6 +62,22 @@
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
+ <dependency>
+ <groupId>dev.langchain4j</groupId>
+ <artifactId>langchain4j-anthropic</artifactId>
Review Comment:
If you run `mvn dependency:tree` this dependency of `okhttp:4.12.0` will be
emitted as a transitive dependency at compile time from this artifact:
`langchain4j-anthropic` and same `okhttp-jvm:5.3.2` of different version will
be emitted at runtime from `opentelemetry-exporter-sender-okhttp` artifact from
main pom.xml. So adding an exclusion for `okhttp-jvm` in the recon pom.xml
should be done.
##########
pom.xml:
##########
@@ -515,6 +569,26 @@
</exclusion>
</exclusions>
</dependency>
+ <dependency>
+ <groupId>dev.langchain4j</groupId>
+ <artifactId>langchain4j-anthropic</artifactId>
+ <version>0.36.2</version>
+ </dependency>
+ <dependency>
+ <groupId>dev.langchain4j</groupId>
+ <artifactId>langchain4j-core</artifactId>
+ <version>0.36.2</version>
+ </dependency>
+ <dependency>
+ <groupId>dev.langchain4j</groupId>
+ <artifactId>langchain4j-google-ai-gemini</artifactId>
+ <version>0.36.2</version>
+ </dependency>
+ <dependency>
+ <groupId>dev.langchain4j</groupId>
+ <artifactId>langchain4j-open-ai</artifactId>
+ <version>0.36.2</version>
+ </dependency>
Review Comment:
each langchain4j-* artifact added separately at 0.36.2 version rather than
importing the langchain4j-bom. If need update of version later for 4 separate
version entries, we should use bom
##########
hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/llm/LangChain4jDispatcher.java:
##########
@@ -0,0 +1,405 @@
+/*
+ * 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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.hadoop.ozone.recon.chatbot.llm;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.anthropic.AnthropicChatModel;
+import dev.langchain4j.model.chat.ChatLanguageModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
+import dev.langchain4j.model.openai.OpenAiChatModel;
+import dev.langchain4j.model.output.TokenUsage;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.recon.chatbot.ChatbotConfigKeys;
+import org.apache.hadoop.ozone.recon.chatbot.security.CredentialHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * {@link LLMClient} implementation that sends chat requests to cloud LLM
providers using
+ * <a href="https://github.com/langchain4j/langchain4j">LangChain4j</a>.
+ *
+ * <h2>Purpose</h2>
+ * <p>The Recon chatbot needs to call external APIs (OpenAI, Google Gemini,
Anthropic Claude)
+ * with a stable Java contract. This class is the only place that talks to
LangChain4j: it
+ * picks the right provider, builds a {@link ChatLanguageModel} for the
requested model,
+ * converts messages into LangChain4j types, runs one completion, and maps the
result back to
+ * {@link LLMResponse}. Higher layers ({@link
org.apache.hadoop.ozone.recon.chatbot.agent.ChatbotAgent},
+ * {@link org.apache.hadoop.ozone.recon.chatbot.api.ChatbotEndpoint}) depend
only on {@link LLMClient}.</p>
+ *
+ * <h2>Lifecycle (no background work)</h2>
+ * <p>The class is registered in Guice as a singleton: one instance exists for
the whole Recon process.
+ * There is no timer, no scheduled task, and no long-lived outbound
connection. At startup
+ * the constructor only reads configuration and records which providers have
API keys (for
+ * {@link #isAvailable()} and {@link #getSupportedModels()}). Actual network
calls happen
+ * only when {@link #chatCompletion} runs on an HTTP request thread.</p>
+ *
+ * <h2>Request flow (one chat completion)</h2>
+ * <p>Each user message is handled synchronously on the thread that serves the
REST call:</p>
+ * <pre>
+ * User HTTP request
+ * |
+ * v
+ * Jersey dispatches to ChatbotEndpoint (request thread)
+ * |
+ * v
+ * ChatbotAgent orchestrates tool selection / summarization
+ * |
+ * v
+ * LangChain4jDispatcher.chatCompletion(...)
+ * |
+ * +-- Resolve provider (see below)
+ * |
+ * +-- Build a new ChatLanguageModel for that provider + model name
(configuration only)
+ * |
+ * +-- Translate ChatMessage list to LangChain4j messages (system /
user / assistant)
+ * |
+ * +-- chatModel.chat(ChatRequest) --> outbound HTTPS to the
vendor (may take seconds)
+ * |
+ * v
+ * LLMResponse returned to the agent, then JSON to the client
+ * </pre>
+ *
+ * <h2>How provider routing works</h2>
+ * <p>When {@link #chatCompletion} runs, the provider is chosen in this
order:</p>
+ * <ol>
+ * <li>Optional {@code _provider} entry in the parameters map (e.g. {@code
"gemini"}).</li>
+ * <li>If the model string looks like {@code provider:model}, the prefix
before {@code :}
+ * is the provider.</li>
+ * <li>Otherwise the model name: {@code gpt-} / {@code o1} / {@code o3} →
{@code openai};
+ * {@code gemini} → {@code gemini}; {@code claude} → {@code
anthropic}.</li>
+ * <li>If still unclear, {@link
ChatbotConfigKeys#OZONE_RECON_CHATBOT_PROVIDER} is used.</li>
+ * </ol>
+ * <p>For each call, a fresh {@link ChatLanguageModel} is built with the exact
model id the
+ * caller passed (for example {@code gemini-2.5-flash}). That object holds
provider settings
+ * and timeout; the heavy work is the single {@code chat(...)} call. Different
users on
+ * different threads each follow this flow independently; only read-only
configuration is
+ * shared on the dispatcher instance.</p>
+ *
+ * <h2>Supported models listing</h2>
+ * <p>{@link #getSupportedModels()} returns a fixed list per provider for
which a non-empty
+ * API key exists in configuration. It is not a live query to each vendor's
model catalogue.</p>
+ */
+@Singleton
+public class LangChain4jDispatcher implements LLMClient {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LangChain4jDispatcher.class);
+
+ private final OzoneConfiguration configuration;
+ private final CredentialHelper credentialHelper;
+ private final Duration timeout;
+ private final String defaultProvider;
+
+ /**
+ * Per-provider static model lists — used by getSupportedModels() and
isAvailable().
+ * A provider only appears here if its API key is configured.
+ */
+ private final Map<String, List<String>> supportedModels = new HashMap<>();
+
+ @Inject
+ public LangChain4jDispatcher(OzoneConfiguration configuration,
+ CredentialHelper credentialHelper) {
+ this.configuration = configuration;
+ this.credentialHelper = credentialHelper;
+
+ int timeoutMs = configuration.getInt(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_TIMEOUT_MS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_TIMEOUT_MS_DEFAULT);
+ this.timeout = Duration.ofMillis(timeoutMs);
+
+ this.defaultProvider = configuration.get(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_PROVIDER,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_PROVIDER_DEFAULT);
+
+ // Register available providers. A provider is considered "available" only
if
+ // a non-empty API key has been configured for it. Model lists are read
from
+ // ozone-site.xml so admins can update them without a code change when
vendors
+ // rename, add, or retire models.
+ if (!credentialHelper.getSecret(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_API_KEY).isEmpty()) {
+ supportedModels.put("openai", parseModelList(configuration,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_MODELS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_MODELS_DEFAULT));
Review Comment:
Model names cannot be hardcoded, default models should always be fetched
latest at initialization and kept them as default.
##########
hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/llm/LangChain4jDispatcher.java:
##########
@@ -0,0 +1,405 @@
+/*
+ * 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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.hadoop.ozone.recon.chatbot.llm;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.anthropic.AnthropicChatModel;
+import dev.langchain4j.model.chat.ChatLanguageModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
+import dev.langchain4j.model.openai.OpenAiChatModel;
+import dev.langchain4j.model.output.TokenUsage;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.recon.chatbot.ChatbotConfigKeys;
+import org.apache.hadoop.ozone.recon.chatbot.security.CredentialHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * {@link LLMClient} implementation that sends chat requests to cloud LLM
providers using
+ * <a href="https://github.com/langchain4j/langchain4j">LangChain4j</a>.
+ *
+ * <h2>Purpose</h2>
+ * <p>The Recon chatbot needs to call external APIs (OpenAI, Google Gemini,
Anthropic Claude)
+ * with a stable Java contract. This class is the only place that talks to
LangChain4j: it
+ * picks the right provider, builds a {@link ChatLanguageModel} for the
requested model,
+ * converts messages into LangChain4j types, runs one completion, and maps the
result back to
+ * {@link LLMResponse}. Higher layers ({@link
org.apache.hadoop.ozone.recon.chatbot.agent.ChatbotAgent},
+ * {@link org.apache.hadoop.ozone.recon.chatbot.api.ChatbotEndpoint}) depend
only on {@link LLMClient}.</p>
+ *
+ * <h2>Lifecycle (no background work)</h2>
+ * <p>The class is registered in Guice as a singleton: one instance exists for
the whole Recon process.
+ * There is no timer, no scheduled task, and no long-lived outbound
connection. At startup
+ * the constructor only reads configuration and records which providers have
API keys (for
+ * {@link #isAvailable()} and {@link #getSupportedModels()}). Actual network
calls happen
+ * only when {@link #chatCompletion} runs on an HTTP request thread.</p>
+ *
+ * <h2>Request flow (one chat completion)</h2>
+ * <p>Each user message is handled synchronously on the thread that serves the
REST call:</p>
+ * <pre>
+ * User HTTP request
+ * |
+ * v
+ * Jersey dispatches to ChatbotEndpoint (request thread)
+ * |
+ * v
+ * ChatbotAgent orchestrates tool selection / summarization
+ * |
+ * v
+ * LangChain4jDispatcher.chatCompletion(...)
+ * |
+ * +-- Resolve provider (see below)
+ * |
+ * +-- Build a new ChatLanguageModel for that provider + model name
(configuration only)
+ * |
+ * +-- Translate ChatMessage list to LangChain4j messages (system /
user / assistant)
+ * |
+ * +-- chatModel.chat(ChatRequest) --> outbound HTTPS to the
vendor (may take seconds)
+ * |
+ * v
+ * LLMResponse returned to the agent, then JSON to the client
+ * </pre>
+ *
+ * <h2>How provider routing works</h2>
+ * <p>When {@link #chatCompletion} runs, the provider is chosen in this
order:</p>
+ * <ol>
+ * <li>Optional {@code _provider} entry in the parameters map (e.g. {@code
"gemini"}).</li>
+ * <li>If the model string looks like {@code provider:model}, the prefix
before {@code :}
+ * is the provider.</li>
+ * <li>Otherwise the model name: {@code gpt-} / {@code o1} / {@code o3} →
{@code openai};
+ * {@code gemini} → {@code gemini}; {@code claude} → {@code
anthropic}.</li>
+ * <li>If still unclear, {@link
ChatbotConfigKeys#OZONE_RECON_CHATBOT_PROVIDER} is used.</li>
+ * </ol>
+ * <p>For each call, a fresh {@link ChatLanguageModel} is built with the exact
model id the
+ * caller passed (for example {@code gemini-2.5-flash}). That object holds
provider settings
+ * and timeout; the heavy work is the single {@code chat(...)} call. Different
users on
+ * different threads each follow this flow independently; only read-only
configuration is
+ * shared on the dispatcher instance.</p>
+ *
+ * <h2>Supported models listing</h2>
+ * <p>{@link #getSupportedModels()} returns a fixed list per provider for
which a non-empty
+ * API key exists in configuration. It is not a live query to each vendor's
model catalogue.</p>
+ */
+@Singleton
+public class LangChain4jDispatcher implements LLMClient {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LangChain4jDispatcher.class);
+
+ private final OzoneConfiguration configuration;
+ private final CredentialHelper credentialHelper;
+ private final Duration timeout;
+ private final String defaultProvider;
+
+ /**
+ * Per-provider static model lists — used by getSupportedModels() and
isAvailable().
+ * A provider only appears here if its API key is configured.
+ */
+ private final Map<String, List<String>> supportedModels = new HashMap<>();
+
+ @Inject
+ public LangChain4jDispatcher(OzoneConfiguration configuration,
+ CredentialHelper credentialHelper) {
+ this.configuration = configuration;
+ this.credentialHelper = credentialHelper;
+
+ int timeoutMs = configuration.getInt(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_TIMEOUT_MS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_TIMEOUT_MS_DEFAULT);
+ this.timeout = Duration.ofMillis(timeoutMs);
+
+ this.defaultProvider = configuration.get(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_PROVIDER,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_PROVIDER_DEFAULT);
+
+ // Register available providers. A provider is considered "available" only
if
+ // a non-empty API key has been configured for it. Model lists are read
from
+ // ozone-site.xml so admins can update them without a code change when
vendors
+ // rename, add, or retire models.
+ if (!credentialHelper.getSecret(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_API_KEY).isEmpty()) {
+ supportedModels.put("openai", parseModelList(configuration,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_MODELS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_MODELS_DEFAULT));
+ }
+ if (!credentialHelper.getSecret(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GEMINI_API_KEY).isEmpty()) {
+ supportedModels.put("gemini", parseModelList(configuration,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GEMINI_MODELS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GEMINI_MODELS_DEFAULT));
+ }
+ if (!credentialHelper.getSecret(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_ANTHROPIC_API_KEY).isEmpty()) {
+ supportedModels.put("anthropic", parseModelList(configuration,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_ANTHROPIC_MODELS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_ANTHROPIC_MODELS_DEFAULT));
Review Comment:
Same here.
##########
hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/agent/ChatbotAgent.java:
##########
@@ -0,0 +1,773 @@
+/*
+ * 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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.hadoop.ozone.recon.chatbot.agent;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.recon.chatbot.ChatbotConfigKeys;
+import org.apache.hadoop.ozone.recon.chatbot.llm.LLMClient;
+import org.apache.hadoop.ozone.recon.chatbot.llm.LLMClient.ChatMessage;
+import org.apache.hadoop.ozone.recon.chatbot.llm.LLMClient.LLMResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Main chatbot agent that orchestrates the conversation flow.
+ * Handles tool selection (figuring out what API to call), executing those
calls,
+ * and summarization (feeding the data back to the LLM to write a nice answer).
+ */
+@Singleton
+public class ChatbotAgent {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(ChatbotAgent.class);
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final Pattern JSON_PATTERN = Pattern.compile("\\{.*\\}",
Pattern.DOTALL);
Review Comment:
This still needs to resolve.
##########
hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconControllerModule.java:
##########
@@ -131,6 +131,12 @@ protected void configure() {
install(new ReconOmTaskBindingModule());
install(new ReconDaoBindingModule());
bind(ReconTaskStatusUpdaterManager.class).in(Singleton.class);
+ // Only install chatbot bindings when the feature is explicitly enabled.
+ // This prevents startup-time failures (e.g. bad credential provider paths)
+ // from breaking Recon when the chatbot is intentionally disabled.
+ if (ChatbotConfigKeys.isChatbotEnabled(new ConfigurationProvider().get()))
{
Review Comment:
```suggestion
if (ChatbotConfigKeys.isChatbotEnabled(reconServer.getConf())) {
```
##########
hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/llm/LangChain4jDispatcher.java:
##########
@@ -0,0 +1,405 @@
+/*
+ * 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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.hadoop.ozone.recon.chatbot.llm;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.anthropic.AnthropicChatModel;
+import dev.langchain4j.model.chat.ChatLanguageModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
+import dev.langchain4j.model.openai.OpenAiChatModel;
+import dev.langchain4j.model.output.TokenUsage;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.recon.chatbot.ChatbotConfigKeys;
+import org.apache.hadoop.ozone.recon.chatbot.security.CredentialHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * {@link LLMClient} implementation that sends chat requests to cloud LLM
providers using
+ * <a href="https://github.com/langchain4j/langchain4j">LangChain4j</a>.
+ *
+ * <h2>Purpose</h2>
+ * <p>The Recon chatbot needs to call external APIs (OpenAI, Google Gemini,
Anthropic Claude)
+ * with a stable Java contract. This class is the only place that talks to
LangChain4j: it
+ * picks the right provider, builds a {@link ChatLanguageModel} for the
requested model,
+ * converts messages into LangChain4j types, runs one completion, and maps the
result back to
+ * {@link LLMResponse}. Higher layers ({@link
org.apache.hadoop.ozone.recon.chatbot.agent.ChatbotAgent},
+ * {@link org.apache.hadoop.ozone.recon.chatbot.api.ChatbotEndpoint}) depend
only on {@link LLMClient}.</p>
+ *
+ * <h2>Lifecycle (no background work)</h2>
+ * <p>The class is registered in Guice as a singleton: one instance exists for
the whole Recon process.
+ * There is no timer, no scheduled task, and no long-lived outbound
connection. At startup
+ * the constructor only reads configuration and records which providers have
API keys (for
+ * {@link #isAvailable()} and {@link #getSupportedModels()}). Actual network
calls happen
+ * only when {@link #chatCompletion} runs on an HTTP request thread.</p>
+ *
+ * <h2>Request flow (one chat completion)</h2>
+ * <p>Each user message is handled synchronously on the thread that serves the
REST call:</p>
+ * <pre>
+ * User HTTP request
+ * |
+ * v
+ * Jersey dispatches to ChatbotEndpoint (request thread)
+ * |
+ * v
+ * ChatbotAgent orchestrates tool selection / summarization
+ * |
+ * v
+ * LangChain4jDispatcher.chatCompletion(...)
+ * |
+ * +-- Resolve provider (see below)
+ * |
+ * +-- Build a new ChatLanguageModel for that provider + model name
(configuration only)
+ * |
+ * +-- Translate ChatMessage list to LangChain4j messages (system /
user / assistant)
+ * |
+ * +-- chatModel.chat(ChatRequest) --> outbound HTTPS to the
vendor (may take seconds)
+ * |
+ * v
+ * LLMResponse returned to the agent, then JSON to the client
+ * </pre>
+ *
+ * <h2>How provider routing works</h2>
+ * <p>When {@link #chatCompletion} runs, the provider is chosen in this
order:</p>
+ * <ol>
+ * <li>Optional {@code _provider} entry in the parameters map (e.g. {@code
"gemini"}).</li>
+ * <li>If the model string looks like {@code provider:model}, the prefix
before {@code :}
+ * is the provider.</li>
+ * <li>Otherwise the model name: {@code gpt-} / {@code o1} / {@code o3} →
{@code openai};
+ * {@code gemini} → {@code gemini}; {@code claude} → {@code
anthropic}.</li>
+ * <li>If still unclear, {@link
ChatbotConfigKeys#OZONE_RECON_CHATBOT_PROVIDER} is used.</li>
+ * </ol>
+ * <p>For each call, a fresh {@link ChatLanguageModel} is built with the exact
model id the
+ * caller passed (for example {@code gemini-2.5-flash}). That object holds
provider settings
+ * and timeout; the heavy work is the single {@code chat(...)} call. Different
users on
+ * different threads each follow this flow independently; only read-only
configuration is
+ * shared on the dispatcher instance.</p>
+ *
+ * <h2>Supported models listing</h2>
+ * <p>{@link #getSupportedModels()} returns a fixed list per provider for
which a non-empty
+ * API key exists in configuration. It is not a live query to each vendor's
model catalogue.</p>
+ */
+@Singleton
+public class LangChain4jDispatcher implements LLMClient {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LangChain4jDispatcher.class);
+
+ private final OzoneConfiguration configuration;
+ private final CredentialHelper credentialHelper;
+ private final Duration timeout;
+ private final String defaultProvider;
+
+ /**
+ * Per-provider static model lists — used by getSupportedModels() and
isAvailable().
+ * A provider only appears here if its API key is configured.
+ */
+ private final Map<String, List<String>> supportedModels = new HashMap<>();
+
+ @Inject
+ public LangChain4jDispatcher(OzoneConfiguration configuration,
+ CredentialHelper credentialHelper) {
+ this.configuration = configuration;
+ this.credentialHelper = credentialHelper;
+
+ int timeoutMs = configuration.getInt(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_TIMEOUT_MS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_TIMEOUT_MS_DEFAULT);
+ this.timeout = Duration.ofMillis(timeoutMs);
+
+ this.defaultProvider = configuration.get(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_PROVIDER,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_PROVIDER_DEFAULT);
+
+ // Register available providers. A provider is considered "available" only
if
+ // a non-empty API key has been configured for it. Model lists are read
from
+ // ozone-site.xml so admins can update them without a code change when
vendors
+ // rename, add, or retire models.
+ if (!credentialHelper.getSecret(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_API_KEY).isEmpty()) {
+ supportedModels.put("openai", parseModelList(configuration,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_MODELS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_MODELS_DEFAULT));
+ }
+ if (!credentialHelper.getSecret(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GEMINI_API_KEY).isEmpty()) {
+ supportedModels.put("gemini", parseModelList(configuration,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GEMINI_MODELS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GEMINI_MODELS_DEFAULT));
+ }
+ if (!credentialHelper.getSecret(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_ANTHROPIC_API_KEY).isEmpty()) {
+ supportedModels.put("anthropic", parseModelList(configuration,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_ANTHROPIC_MODELS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_ANTHROPIC_MODELS_DEFAULT));
+ }
+
+ LOG.info("LangChain4jDispatcher initialized. Available providers: {},
default: {}",
+ supportedModels.keySet(), defaultProvider);
+ }
+
+ /**
+ * Sends the conversation to the appropriate LLM provider and returns a
standardised response.
+ *
+ * <p>Steps:
+ * <ol>
+ * <li>Determine which provider to use from model name prefix or explicit
provider hint.</li>
+ * <li>Build a LangChain4j {@link ChatLanguageModel} for that provider +
model.</li>
+ * <li>Translate internal {@link ChatMessage} list to LangChain4j message
types.</li>
+ * <li>Call the model, extract text + token counts, return {@link
LLMResponse}.</li>
+ * </ol>
+ */
+ @Override
+ public LLMResponse chatCompletion(List<ChatMessage> messages, String
modelStr, Map<String, Object> parameters)
+ throws LLMException {
+
+ if (messages == null || messages.isEmpty()) {
+ throw new LLMException("Messages cannot be null or empty");
+ }
+
+ // Extract provider hint and actual model name from "provider:model"
format if present.
+ String providerHint = null;
+ String actualModel = modelStr;
+ if (parameters != null && parameters.containsKey("_provider")) {
+ providerHint = (String) parameters.get("_provider");
+ }
+ if (modelStr != null && modelStr.contains(":")) {
+ String[] parts = modelStr.split(":", 2);
+ providerHint = parts[0].toLowerCase();
+ actualModel = parts[1];
+ }
+
+ String provider = resolveProvider(providerHint, actualModel);
+ LOG.debug("Routing chatCompletion: model={}, resolvedProvider={}",
actualModel, provider);
+
+ // Build the LangChain4j model for this specific request.
+ ChatLanguageModel chatModel = buildModel(provider, actualModel);
Review Comment:
This `buildModel` creates a new HTTP client on every request (Performance).
A new `ChatLanguageModel` — which internally builds an HTTP client with
connection pool and SSL context — is constructed and thrown away on every
`chatCompletion()` call. The models should be cached per (provider, modelName).
##########
hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/llm/LangChain4jDispatcher.java:
##########
@@ -0,0 +1,405 @@
+/*
+ * 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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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.hadoop.ozone.recon.chatbot.llm;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.anthropic.AnthropicChatModel;
+import dev.langchain4j.model.chat.ChatLanguageModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
+import dev.langchain4j.model.openai.OpenAiChatModel;
+import dev.langchain4j.model.output.TokenUsage;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.recon.chatbot.ChatbotConfigKeys;
+import org.apache.hadoop.ozone.recon.chatbot.security.CredentialHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * {@link LLMClient} implementation that sends chat requests to cloud LLM
providers using
+ * <a href="https://github.com/langchain4j/langchain4j">LangChain4j</a>.
+ *
+ * <h2>Purpose</h2>
+ * <p>The Recon chatbot needs to call external APIs (OpenAI, Google Gemini,
Anthropic Claude)
+ * with a stable Java contract. This class is the only place that talks to
LangChain4j: it
+ * picks the right provider, builds a {@link ChatLanguageModel} for the
requested model,
+ * converts messages into LangChain4j types, runs one completion, and maps the
result back to
+ * {@link LLMResponse}. Higher layers ({@link
org.apache.hadoop.ozone.recon.chatbot.agent.ChatbotAgent},
+ * {@link org.apache.hadoop.ozone.recon.chatbot.api.ChatbotEndpoint}) depend
only on {@link LLMClient}.</p>
+ *
+ * <h2>Lifecycle (no background work)</h2>
+ * <p>The class is registered in Guice as a singleton: one instance exists for
the whole Recon process.
+ * There is no timer, no scheduled task, and no long-lived outbound
connection. At startup
+ * the constructor only reads configuration and records which providers have
API keys (for
+ * {@link #isAvailable()} and {@link #getSupportedModels()}). Actual network
calls happen
+ * only when {@link #chatCompletion} runs on an HTTP request thread.</p>
+ *
+ * <h2>Request flow (one chat completion)</h2>
+ * <p>Each user message is handled synchronously on the thread that serves the
REST call:</p>
+ * <pre>
+ * User HTTP request
+ * |
+ * v
+ * Jersey dispatches to ChatbotEndpoint (request thread)
+ * |
+ * v
+ * ChatbotAgent orchestrates tool selection / summarization
+ * |
+ * v
+ * LangChain4jDispatcher.chatCompletion(...)
+ * |
+ * +-- Resolve provider (see below)
+ * |
+ * +-- Build a new ChatLanguageModel for that provider + model name
(configuration only)
+ * |
+ * +-- Translate ChatMessage list to LangChain4j messages (system /
user / assistant)
+ * |
+ * +-- chatModel.chat(ChatRequest) --> outbound HTTPS to the
vendor (may take seconds)
+ * |
+ * v
+ * LLMResponse returned to the agent, then JSON to the client
+ * </pre>
+ *
+ * <h2>How provider routing works</h2>
+ * <p>When {@link #chatCompletion} runs, the provider is chosen in this
order:</p>
+ * <ol>
+ * <li>Optional {@code _provider} entry in the parameters map (e.g. {@code
"gemini"}).</li>
+ * <li>If the model string looks like {@code provider:model}, the prefix
before {@code :}
+ * is the provider.</li>
+ * <li>Otherwise the model name: {@code gpt-} / {@code o1} / {@code o3} →
{@code openai};
+ * {@code gemini} → {@code gemini}; {@code claude} → {@code
anthropic}.</li>
+ * <li>If still unclear, {@link
ChatbotConfigKeys#OZONE_RECON_CHATBOT_PROVIDER} is used.</li>
+ * </ol>
+ * <p>For each call, a fresh {@link ChatLanguageModel} is built with the exact
model id the
+ * caller passed (for example {@code gemini-2.5-flash}). That object holds
provider settings
+ * and timeout; the heavy work is the single {@code chat(...)} call. Different
users on
+ * different threads each follow this flow independently; only read-only
configuration is
+ * shared on the dispatcher instance.</p>
+ *
+ * <h2>Supported models listing</h2>
+ * <p>{@link #getSupportedModels()} returns a fixed list per provider for
which a non-empty
+ * API key exists in configuration. It is not a live query to each vendor's
model catalogue.</p>
+ */
+@Singleton
+public class LangChain4jDispatcher implements LLMClient {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LangChain4jDispatcher.class);
+
+ private final OzoneConfiguration configuration;
+ private final CredentialHelper credentialHelper;
+ private final Duration timeout;
+ private final String defaultProvider;
+
+ /**
+ * Per-provider static model lists — used by getSupportedModels() and
isAvailable().
+ * A provider only appears here if its API key is configured.
+ */
+ private final Map<String, List<String>> supportedModels = new HashMap<>();
+
+ @Inject
+ public LangChain4jDispatcher(OzoneConfiguration configuration,
+ CredentialHelper credentialHelper) {
+ this.configuration = configuration;
+ this.credentialHelper = credentialHelper;
+
+ int timeoutMs = configuration.getInt(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_TIMEOUT_MS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_TIMEOUT_MS_DEFAULT);
+ this.timeout = Duration.ofMillis(timeoutMs);
+
+ this.defaultProvider = configuration.get(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_PROVIDER,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_PROVIDER_DEFAULT);
+
+ // Register available providers. A provider is considered "available" only
if
+ // a non-empty API key has been configured for it. Model lists are read
from
+ // ozone-site.xml so admins can update them without a code change when
vendors
+ // rename, add, or retire models.
+ if (!credentialHelper.getSecret(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_API_KEY).isEmpty()) {
+ supportedModels.put("openai", parseModelList(configuration,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_MODELS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_MODELS_DEFAULT));
+ }
+ if (!credentialHelper.getSecret(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GEMINI_API_KEY).isEmpty()) {
+ supportedModels.put("gemini", parseModelList(configuration,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GEMINI_MODELS,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GEMINI_MODELS_DEFAULT));
Review Comment:
Same here.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]