This is an automated email from the ASF dual-hosted git repository.
ArafatKhan2198 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git
The following commit(s) were added to refs/heads/master by this push:
new 7c54977e640 HDDS-15906. Add gateway LLM provider for Recon chatbot
(OpenAI-compatible gateways). (#10805).
7c54977e640 is described below
commit 7c54977e640fd5939b417c8237a726dc71fd8145
Author: Arafat2198 <[email protected]>
AuthorDate: Sun Jul 19 14:33:40 2026 +0530
HDDS-15906. Add gateway LLM provider for Recon chatbot (OpenAI-compatible
gateways). (#10805).
Co-authored-by: Cursor <[email protected]>
---
.../dist/src/main/compose/ozone-ha/docker-config | 26 ++++++++++
.../dist/src/main/compose/ozone/docker-config | 14 +++++-
.../src/main/compose/ozonesecure/docker-config | 26 ++++++++++
.../ozone/recon/chatbot/ChatbotConfigKeys.java | 20 ++++++++
.../recon/chatbot/llm/LangChain4jDispatcher.java | 27 ++++++++++
.../chatbot/llm/TestLangChain4jDispatcher.java | 57 ++++++++++++++++++++++
.../ozone/recon/chatbot/llm/TestLlmRouting.java | 8 +++
7 files changed, 176 insertions(+), 2 deletions(-)
diff --git a/hadoop-ozone/dist/src/main/compose/ozone-ha/docker-config
b/hadoop-ozone/dist/src/main/compose/ozone-ha/docker-config
index b2479944e9e..0ee670aabc2 100644
--- a/hadoop-ozone/dist/src/main/compose/ozone-ha/docker-config
+++ b/hadoop-ozone/dist/src/main/compose/ozone-ha/docker-config
@@ -56,3 +56,29 @@ OZONE_CONF_DIR=/etc/hadoop
OZONE_LOG_DIR=/var/log/hadoop
no_proxy=om1,om2,om3,scm,s3g,recon,kdc,localhost,127.0.0.1
+
+# Recon AI Chatbot — DISABLED by default.
+#
+# WARNING: The plaintext API key approach shown below is for LOCAL DOCKER
+# TESTING ONLY. It must NOT be used on production clusters because plaintext
+# keys are exposed via 'hadoop conf | grep api.key' and via Recon's /conf HTTP
+# endpoint. For production clusters, store the key in a Hadoop JCEKS credential
+# store instead (see ozone-site.xml.template for full setup instructions).
+#
+# To enable the chatbot locally for testing:
+# 1. Uncomment ONE block below (direct provider or gateway).
+# 2. Replace the placeholder key with a real key.
+# 3. Never commit a real key to git — rotate it immediately if you do.
+#
+# --- Direct provider (Gemini example; OpenAI and Anthropic are also
supported) ---
+# OZONE-SITE.XML_ozone.recon.chatbot.enabled=true
+# OZONE-SITE.XML_ozone.recon.chatbot.provider=gemini
+# OZONE-SITE.XML_ozone.recon.chatbot.gemini.api.key=YOUR_GEMINI_API_KEY_HERE
+#
+# --- OpenAI-compatible gateway (LiteLLM etc.) — one door for all models ---
+# OZONE-SITE.XML_ozone.recon.chatbot.enabled=true
+# OZONE-SITE.XML_ozone.recon.chatbot.provider=gateway
+# OZONE-SITE.XML_ozone.recon.chatbot.gateway.base.url=https://your-gateway/v1
+# OZONE-SITE.XML_ozone.recon.chatbot.gateway.api.key=YOUR_GATEWAY_KEY_HERE
+#
OZONE-SITE.XML_ozone.recon.chatbot.gateway.models=gpt-4.1,claude-sonnet,gemini-2.5-flash
+# OZONE-SITE.XML_ozone.recon.chatbot.default.model=claude-sonnet
diff --git a/hadoop-ozone/dist/src/main/compose/ozone/docker-config
b/hadoop-ozone/dist/src/main/compose/ozone/docker-config
index 941969661fd..9ae3bb91514 100644
--- a/hadoop-ozone/dist/src/main/compose/ozone/docker-config
+++ b/hadoop-ozone/dist/src/main/compose/ozone/docker-config
@@ -89,9 +89,19 @@ OZONE-SITE.XML_ozone.snapshot.defrag.service.interval=30s
# store instead (see ozone-site.xml.template for full setup instructions).
#
# To enable the chatbot locally for testing:
-# 1. Uncomment the lines below.
-# 2. Replace YOUR_GEMINI_API_KEY_HERE with a real key.
+# 1. Uncomment ONE block below (direct provider or gateway).
+# 2. Replace the placeholder key with a real key.
# 3. Never commit a real key to git — rotate it immediately if you do.
+#
+# --- Direct provider (Gemini example; OpenAI and Anthropic are also
supported) ---
# OZONE-SITE.XML_ozone.recon.chatbot.enabled=true
# OZONE-SITE.XML_ozone.recon.chatbot.provider=gemini
# OZONE-SITE.XML_ozone.recon.chatbot.gemini.api.key=YOUR_GEMINI_API_KEY_HERE
+#
+# --- OpenAI-compatible gateway (LiteLLM etc.) — one door for all models ---
+# OZONE-SITE.XML_ozone.recon.chatbot.enabled=true
+# OZONE-SITE.XML_ozone.recon.chatbot.provider=gateway
+# OZONE-SITE.XML_ozone.recon.chatbot.gateway.base.url=https://your-gateway/v1
+# OZONE-SITE.XML_ozone.recon.chatbot.gateway.api.key=YOUR_GATEWAY_KEY_HERE
+#
OZONE-SITE.XML_ozone.recon.chatbot.gateway.models=gpt-4.1,claude-sonnet,gemini-2.5-flash
+# OZONE-SITE.XML_ozone.recon.chatbot.default.model=claude-sonnet
diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config
b/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config
index c8ef489a600..2fc32beeb97 100644
--- a/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config
+++ b/hadoop-ozone/dist/src/main/compose/ozonesecure/docker-config
@@ -180,3 +180,29 @@ OZONE-SITE.XML_ozone.om.tenant.dev.skip.ranger=true
# Explicitly enable filesystem snapshot feature for this Docker compose cluster
OZONE-SITE.XML_ozone.filesystem.snapshot.enabled=true
+
+# Recon AI Chatbot — DISABLED by default.
+#
+# WARNING: The plaintext API key approach shown below is for LOCAL DOCKER
+# TESTING ONLY. It must NOT be used on production clusters because plaintext
+# keys are exposed via 'hadoop conf | grep api.key' and via Recon's /conf HTTP
+# endpoint. For production clusters, store the key in a Hadoop JCEKS credential
+# store instead (see ozone-site.xml.template for full setup instructions).
+#
+# To enable the chatbot locally for testing:
+# 1. Uncomment ONE block below (direct provider or gateway).
+# 2. Replace the placeholder key with a real key.
+# 3. Never commit a real key to git — rotate it immediately if you do.
+#
+# --- Direct provider (Gemini example; OpenAI and Anthropic are also
supported) ---
+# OZONE-SITE.XML_ozone.recon.chatbot.enabled=true
+# OZONE-SITE.XML_ozone.recon.chatbot.provider=gemini
+# OZONE-SITE.XML_ozone.recon.chatbot.gemini.api.key=YOUR_GEMINI_API_KEY_HERE
+#
+# --- OpenAI-compatible gateway (LiteLLM etc.) — one door for all models ---
+# OZONE-SITE.XML_ozone.recon.chatbot.enabled=true
+# OZONE-SITE.XML_ozone.recon.chatbot.provider=gateway
+# OZONE-SITE.XML_ozone.recon.chatbot.gateway.base.url=https://your-gateway/v1
+# OZONE-SITE.XML_ozone.recon.chatbot.gateway.api.key=YOUR_GATEWAY_KEY_HERE
+#
OZONE-SITE.XML_ozone.recon.chatbot.gateway.models=gpt-4.1,claude-sonnet,gemini-2.5-flash
+# OZONE-SITE.XML_ozone.recon.chatbot.default.model=claude-sonnet
diff --git
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/ChatbotConfigKeys.java
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/ChatbotConfigKeys.java
index 25ba763e133..85d2283524c 100644
---
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/ChatbotConfigKeys.java
+++
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/ChatbotConfigKeys.java
@@ -55,6 +55,12 @@ public final class ChatbotConfigKeys {
public static final String OZONE_RECON_CHATBOT_ANTHROPIC_API_KEY =
OZONE_RECON_CHATBOT_PREFIX
+ "anthropic.api.key";
+ /**
+ * Gateway API key. Used when provider is set to "gateway" to route all
requests
+ * (regardless of underlying model) through an OpenAI-compatible gateway
(e.g. LiteLLM).
+ */
+ public static final String OZONE_RECON_CHATBOT_GATEWAY_API_KEY =
OZONE_RECON_CHATBOT_PREFIX + "gateway.api.key";
+
// ── Per-provider base URL overrides (optional) ──────────────
public static final String OZONE_RECON_CHATBOT_OPENAI_BASE_URL =
OZONE_RECON_CHATBOT_PREFIX + "openai.base.url";
public static final String OZONE_RECON_CHATBOT_OPENAI_BASE_URL_DEFAULT =
"https://api.openai.com";
@@ -62,6 +68,12 @@ public final class ChatbotConfigKeys {
public static final String OZONE_RECON_CHATBOT_GEMINI_BASE_URL =
OZONE_RECON_CHATBOT_PREFIX + "gemini.base.url";
public static final String OZONE_RECON_CHATBOT_GEMINI_BASE_URL_DEFAULT =
"https://generativelanguage.googleapis.com/v1beta/openai/";
+ /**
+ * Required base URL when using the "gateway" provider. Points to your
internal
+ * OpenAI-compatible endpoint. No default is provided.
+ */
+ public static final String OZONE_RECON_CHATBOT_GATEWAY_BASE_URL =
OZONE_RECON_CHATBOT_PREFIX + "gateway.base.url";
+
// ── Execution policy ────────────────────────────────────────
public static final String OZONE_RECON_CHATBOT_EXEC_REQUIRE_SAFE_SCOPE =
OZONE_RECON_CHATBOT_PREFIX
@@ -141,6 +153,14 @@ public final class ChatbotConfigKeys {
public static final String OZONE_RECON_CHATBOT_ANTHROPIC_MODELS_DEFAULT =
"claude-opus-4-6,claude-sonnet-4-6";
+ /**
+ * Comma-separated list of model aliases exposed by your OpenAI-compatible
gateway.
+ * Required when using the "gateway" provider. Include all models (Claude,
Gemini, GPT)
+ * that your gateway supports. No default is provided.
+ */
+ public static final String OZONE_RECON_CHATBOT_GATEWAY_MODELS =
+ OZONE_RECON_CHATBOT_PREFIX + "gateway.models";
+
// ── Anthropic-specific headers ───────────────────────────────
/**
* Controls the Anthropic beta feature header sent with every request.
diff --git
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/llm/LangChain4jDispatcher.java
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/llm/LangChain4jDispatcher.java
index b13d9e174b8..2af750eef04 100644
---
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/llm/LangChain4jDispatcher.java
+++
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/chatbot/llm/LangChain4jDispatcher.java
@@ -79,6 +79,7 @@ public class LangChain4jDispatcher implements LLMClient {
private static final String PROVIDER_OPENAI = "openai";
private static final String PROVIDER_GEMINI = "gemini";
private static final String PROVIDER_ANTHROPIC = "anthropic";
+ private static final String PROVIDER_GATEWAY = "gateway";
private final OzoneConfiguration configuration;
private final CredentialHelper credentialHelper;
@@ -147,6 +148,12 @@ public LangChain4jDispatcher(OzoneConfiguration
configuration,
ChatbotConfigKeys.OZONE_RECON_CHATBOT_ANTHROPIC_MODELS,
ChatbotConfigKeys.OZONE_RECON_CHATBOT_ANTHROPIC_MODELS_DEFAULT));
}
+ if (!credentialHelper.getSecret(
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_API_KEY).isEmpty()) {
+ supportedModels.put("gateway", parseModelList(configuration,
+ ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_MODELS,
+ ""));
+ }
this.routing = new LlmRouting(defaultProvider, defaultModel,
supportedModels);
@@ -363,11 +370,31 @@ private ChatLanguageModel buildModelInternal(String
provider, String model,
return buildGeminiModel(model, temperature, maxTokens);
case PROVIDER_ANTHROPIC:
return buildAnthropicModel(model, temperature, maxTokens);
+ case PROVIDER_GATEWAY:
+ return buildGatewayModel(model, temperature, maxTokens);
default:
throw new LLMException("Unknown or unconfigured provider: '" + provider
+ "'");
}
}
+ private ChatLanguageModel buildGatewayModel(String model, double
temperature, int maxTokens)
+ throws LLMException {
+ String key =
resolveKey(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_API_KEY, "gateway");
+ String baseUrl =
configuration.get(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_BASE_URL);
+ if (StringUtils.isBlank(baseUrl)) {
+ throw new
LLMException(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_BASE_URL
+ + " must be set when using the gateway provider.");
+ }
+
+ OpenAiChatModel.OpenAiChatModelBuilder builder = OpenAiChatModel.builder()
+ .apiKey(key)
+ .modelName(model)
+ .baseUrl(baseUrl)
+ .timeout(timeout);
+ applyGenerationParams(builder, temperature, maxTokens);
+ return builder.build();
+ }
+
private ChatLanguageModel buildOpenAiModel(String model, double temperature,
int maxTokens)
throws LLMException {
String key =
resolveKey(ChatbotConfigKeys.OZONE_RECON_CHATBOT_OPENAI_API_KEY, "openai");
diff --git
a/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/chatbot/llm/TestLangChain4jDispatcher.java
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/chatbot/llm/TestLangChain4jDispatcher.java
index 1e253fbad3b..fec1b73c533 100644
---
a/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/chatbot/llm/TestLangChain4jDispatcher.java
+++
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/chatbot/llm/TestLangChain4jDispatcher.java
@@ -75,6 +75,13 @@ public void testIsAvailableWithGeminiKey() {
assertTrue(dispatcher.isAvailable());
}
+ @Test
+ public void testIsAvailableWithGatewayKey() {
+ conf.set(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_API_KEY,
"fake-gateway-key");
+ dispatcher = new LangChain4jDispatcher(conf, new CredentialHelper(conf));
+ assertTrue(dispatcher.isAvailable());
+ }
+
@Test
public void testGetSupportedModelsEmptyWithoutKeys() {
List<String> models = dispatcher.getSupportedModels();
@@ -91,6 +98,17 @@ public void testGetSupportedModelsWithGeminiKey() {
assertTrue(models.stream().anyMatch(m -> m.startsWith("gemini")));
}
+ @Test
+ public void testGetSupportedModelsWithGatewayKey() {
+ conf.set(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_API_KEY,
"fake-gateway-key");
+ conf.set(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_MODELS,
"claude-sonnet,gpt-4.1");
+ dispatcher = new LangChain4jDispatcher(conf, new CredentialHelper(conf));
+ List<String> models = dispatcher.getSupportedModels();
+ assertFalse(models.isEmpty());
+ assertTrue(models.contains("claude-sonnet"));
+ assertTrue(models.contains("gpt-4.1"));
+ }
+
@Test
public void testUnknownModelFallsBackToDefaultProvider() {
List<LLMClient.ChatMessage> messages = new ArrayList<>();
@@ -166,4 +184,43 @@ public void
testMismatchedProviderAndModelFallsBackToDefaults() {
assertTrue(ex.getMessage().toLowerCase().contains("gemini"),
"Mismatched pair should fall back to default gemini provider");
}
+
+ @Test
+ public void testGatewayProviderRoutesCorrectly() {
+ conf.set(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_API_KEY,
"fake-gateway-key");
+ conf.set(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_BASE_URL,
"https://gateway.example.com");
+ conf.set(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_MODELS,
"claude-sonnet");
+ dispatcher = new LangChain4jDispatcher(conf, new CredentialHelper(conf));
+
+ List<LLMClient.ChatMessage> messages = new ArrayList<>();
+ messages.add(new LLMClient.ChatMessage("user", "hello"));
+
+ // Fake URL is not reachable; routing past key/base-url validation means
we attempted gateway.
+ Exception ex = assertThrows(Exception.class, () ->
+ dispatcher.chatCompletion(messages, "claude-sonnet", "gateway",
+ new GenParams(0.1, 1000), null));
+ String msg = ex.getMessage() != null ? ex.getMessage() : "";
+ assertFalse(msg.contains("must be set when using the gateway provider"),
+ "Should not fail on missing gateway.base.url");
+ assertFalse(msg.contains("No API key configured"),
+ "Should not fail on missing gateway API key");
+ }
+
+ @Test
+ public void testGatewayProviderThrowsWhenBaseUrlBlank() {
+ conf.set(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_API_KEY,
"fake-gateway-key");
+ conf.set(ChatbotConfigKeys.OZONE_RECON_CHATBOT_GATEWAY_MODELS,
"claude-sonnet");
+ // Leave base URL blank
+ dispatcher = new LangChain4jDispatcher(conf, new CredentialHelper(conf));
+
+ List<LLMClient.ChatMessage> messages = new ArrayList<>();
+ messages.add(new LLMClient.ChatMessage("user", "hello"));
+
+ LLMClient.LLMException ex = assertThrows(LLMClient.LLMException.class, ()
->
+ dispatcher.chatCompletion(messages, "claude-sonnet", "gateway",
+ new GenParams(0.1, 1000), null));
+
+ assertTrue(ex.getMessage().contains("must be set when using the gateway
provider"),
+ "Should throw if gateway.base.url is missing");
+ }
}
diff --git
a/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/chatbot/llm/TestLlmRouting.java
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/chatbot/llm/TestLlmRouting.java
index 270eb2bb626..1e772213d2a 100644
---
a/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/chatbot/llm/TestLlmRouting.java
+++
b/hadoop-ozone/recon/src/test/java/org/apache/hadoop/ozone/recon/chatbot/llm/TestLlmRouting.java
@@ -39,6 +39,7 @@ public void setUp() {
Map<String, List<String>> supportedModels = new HashMap<>();
supportedModels.put("gemini", Arrays.asList("gemini-2.5-flash",
"gemini-2.5-pro"));
supportedModels.put("openai", Arrays.asList("gpt-4.1", "gpt-4.1-mini"));
+ supportedModels.put("gateway", Arrays.asList("claude-sonnet",
"gpt-4.1-gateway"));
routing = new LlmRouting(DEFAULT_PROVIDER, DEFAULT_MODEL, supportedModels);
}
@@ -49,6 +50,13 @@ public void testValidProviderAndModel() {
assertEquals("gpt-4.1", resolved.getModel());
}
+ @Test
+ public void testValidGatewayProviderAndModel() {
+ LlmRouting.Resolved resolved = routing.resolve("gateway", "claude-sonnet");
+ assertEquals("gateway", resolved.getProvider());
+ assertEquals("claude-sonnet", resolved.getModel());
+ }
+
@Test
public void testSupportedModelInfersProviderWhenProviderMissing() {
LlmRouting.Resolved resolved = routing.resolve(null, "gpt-4.1");
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]