diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json index 82fbe66b7ed2e..bd4b5b9e9ad7b 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json @@ -53,7 +53,8 @@ "CamelLangChain4jAgentRequestModel": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#REQUEST_MODEL" }, "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name. Not set by the agent producer when langchain4j Result does not expose it.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, - "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" } + "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" }, + "CamelLangChain4jAgentModerationFlagged": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Set to true when user input is rejected by the configured moderation model; not set on success.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED" } }, "properties": { "agentId": { "index": 0, "kind": "path", "displayName": "Agent Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Agent id" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json index e3d001e3183e4..b980d31ba932a 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles-openapi.json @@ -6842,6 +6842,16 @@ "default": false } }, + { + "name": "kamelets", + "in": "query", + "description": "Whether to include routes created by Kamelets. These are hidden by default, as they are an implementation detail of the Kamelet", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, { "name": "metric", "in": "query", diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json index 7c4fe2e36fb6f..26284535e9337 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/dev-consoles/route-topology.json @@ -28,9 +28,24 @@ "defaultValue": false, "description": "Whether to include external systems (databases, messaging brokers, etc.) as nodes" }, - "metric": { + "kamelets": { "index": 1, "kind": "option", + "displayName": "Kamelets", + "group": "query", + "label": "query", + "required": false, + "type": "boolean", + "javaType": "java.lang.Boolean", + "deprecated": false, + "autowired": false, + "secret": false, + "defaultValue": false, + "description": "Whether to include routes created by Kamelets. These are hidden by default, as they are an implementation detail of the Kamelet" + }, + "metric": { + "index": 2, + "kind": "option", "displayName": "Metric", "group": "query", "label": "query", @@ -44,7 +59,7 @@ "description": "Whether to include live metrics (message counts) on nodes and edges" }, "routes": { - "index": 2, + "index": 3, "kind": "option", "displayName": "Routes", "group": "query", diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc index 5d2fd309ffa91..71e28b536f502 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc @@ -158,6 +158,7 @@ Agents are configured using the `AgentConfiguration` class which provides a flue * Chat Memory Provider (for memory-enabled agents) * Retrieval Augmentor (for RAG functionality) * Input and Output Guardrails +* Content moderation (`withModerationModel`) * Concurrent tool execution (`withExecuteToolsConcurrently`) for parallel Camel route tools and MCP tools within one LLM round trip * Tool-calling control: round-trip limits, hallucinated tool handling, and error compensation * AiServices builder customizer for advanced LangChain4j options @@ -178,6 +179,28 @@ Agents are configured using the `AgentConfiguration` class which provides a flue | `withExecuteToolsConcurrently()` / `withExecuteToolsConcurrently(Executor)` | Runs multiple tool calls from one LLM turn in parallel |=== +==== Content moderation + +When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:others:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. The flagged text is available via `${exception.moderation}` when handling `ModerationException`. + +._Java-only: public support chatbot with moderation_ +[source,java] +---- +AgentConfiguration configuration = new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(openAiModerationModel); + +context.getRegistry().bind("supportAgentConfig", configuration); + +onException(ModerationException.class) + .handled(true) + .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(422)) + .setBody(constant("Sorry, your message violates our usage policy.")); + +from("platform-http:/support/chat") + .to("langchain4j-agent:support?agentConfiguration=#supportAgentConfig&tags=orders,billing"); +---- + ._Java-only: recover from a hallucinated tool name_ [source,java] ---- diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc index c79d94ec21ba5..41244944ffb10 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-guardrails.adoc @@ -610,12 +610,44 @@ public class AgentConfig { } ---- +=== Content Moderation + +Configure a LangChain4j `ModerationModel` on `AgentConfiguration.withModerationModel(...)`. Camel pre-moderates the user message before the chat model is invoked, so flagged input raises `ModerationException` before tools run or conversation memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` when moderation flags the input so routes can return a controlled refusal. Provider infrastructure failures are rethrown without setting the flagged header. + +Moderation runs before LangChain4j input guardrails, which execute inside the AiServices call. Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy checks to a provider API (OpenAI Moderation, Mistral moderation, etc.). Only the user message text is moderated; system prompts configured by the route author are not sent to the moderation model. + +Custom `Agent` implementations should call `ModerationSupport.moderateUserMessage(...)` when they bypass `AgentWithMemory` / `AgentWithoutMemory`. Moderation failures are policy rejections (`GenAiErrorCategory.VALIDATION`), not malformed requests to retry blindly. + +._Java-only: public support chatbot with moderation and controlled refusal_ +[source,java] +---- +AgentConfiguration configuration = new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(openAiModerationModel); + +context.getRegistry().bind("supportAgentConfig", configuration); + +onException(ModerationException.class) + .handled(true) + .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(422)) + .setBody(constant("Sorry, your message violates our usage policy.")); + +from("platform-http:/support/chat") + .to("langchain4j-agent:support?agentConfiguration=#supportAgentConfig&tags=orders,billing"); +---- + +In Spring Boot, inject an auto-configured `ModerationModel` bean from the LangChain4j starter alongside `ChatModel`. + === Guardrail Behavior Summary [cols="1,1,2"] |=== |Guardrail |When Triggered |Result +|`Content moderation` +|User message flagged by moderation model +|Blocks the request before input guardrails and chat model + |`InputLengthGuardrail` |Input too short/long |Blocks the request diff --git a/components/camel-ai/camel-ai-observability-api/pom.xml b/components/camel-ai/camel-ai-observability-api/pom.xml index b61b987baa867..7c10e07355a4c 100644 --- a/components/camel-ai/camel-ai-observability-api/pom.xml +++ b/components/camel-ai/camel-ai-observability-api/pom.xml @@ -49,6 +49,12 @@ ${langchain4j-version} test + + dev.langchain4j + langchain4j + ${langchain4j-version} + test + org.apache.camel diff --git a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java index dc7ce3ccc68de..d7eab30ff7d5b 100644 --- a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java +++ b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java @@ -48,7 +48,8 @@ public final class GenAiErrorSupport { Map.entry("dev.langchain4j.exception.InvalidRequestException", GenAiErrorCategory.VALIDATION), Map.entry("dev.langchain4j.exception.ContentFilteredException", GenAiErrorCategory.VALIDATION), Map.entry("dev.langchain4j.exception.ModelNotFoundException", GenAiErrorCategory.VALIDATION), - Map.entry("dev.langchain4j.exception.ToolArgumentsException", GenAiErrorCategory.VALIDATION)); + Map.entry("dev.langchain4j.exception.ToolArgumentsException", GenAiErrorCategory.VALIDATION), + Map.entry("dev.langchain4j.service.ModerationException", GenAiErrorCategory.VALIDATION)); private static final Map OPENAI_EXCEPTION_CATEGORIES = Map.ofEntries( Map.entry("com.openai.errors.RateLimitException", GenAiErrorCategory.RATE_LIMIT), diff --git a/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java b/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java index 1ce0b4ad46bad..b44074a68eaf7 100644 --- a/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java +++ b/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java @@ -21,6 +21,8 @@ import dev.langchain4j.exception.InternalServerException; import dev.langchain4j.exception.InvalidRequestException; import dev.langchain4j.exception.RateLimitException; +import dev.langchain4j.model.moderation.Moderation; +import dev.langchain4j.service.ModerationException; import org.apache.camel.support.DefaultExchange; import org.apache.camel.test.junit6.CamelTestSupport; import org.junit.jupiter.api.Test; @@ -98,6 +100,29 @@ void shouldApplyCategoryPropertyToExchange() { assertThat(exchange.getProperty(GenAiErrorProperties.RETRY_AFTER_MILLIS)).isNull(); } + @Test + void shouldClassifyModerationExceptionAsValidation() { + Moderation moderation = Moderation.flagged("bad"); + assertThat(GenAiErrorSupport.classify(new ModerationException("flagged", moderation))) + .isEqualTo(GenAiErrorCategory.VALIDATION); + } + + @Test + void shouldClassifyWrappedModerationExceptionAsValidation() { + Moderation moderation = Moderation.flagged("bad"); + RuntimeException wrapped = new RuntimeException("outer", new ModerationException("flagged", moderation)); + assertThat(GenAiErrorSupport.classify(wrapped)).isEqualTo(GenAiErrorCategory.VALIDATION); + } + + @Test + void shouldApplyModerationExceptionCategoryToExchange() { + DefaultExchange exchange = new DefaultExchange(context); + GenAiErrorSupport.apply(exchange, new ModerationException("flagged", Moderation.flagged("bad"))); + + assertThat(exchange.getProperty(GenAiErrorProperties.ERROR_CATEGORY, String.class)) + .isEqualTo(GenAiErrorCategory.VALIDATION.name()); + } + @Test void shouldReturnUnknownForUnrecognizedException() { assertThat(GenAiErrorSupport.classify(new IllegalStateException("boom"))) diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java index 2cd440ec8f236..e000b533d4b14 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java @@ -125,7 +125,6 @@ public void setResponseFormat(ResponseFormat responseFormat) { * @param builder the AI services builder to configure * @param toolProvider the Apache Camel tool provider (may be null) */ - @SuppressWarnings("unchecked") protected void configureBuilder(AiServices builder, ToolProvider toolProvider) { // Collect all tool providers to compose them into a single provider List toolProviders = new ArrayList<>(); diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java index d7f300c7f2bd0..d45a09af72a1d 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentConfiguration.java @@ -33,6 +33,7 @@ import dev.langchain4j.mcp.client.McpClient; import dev.langchain4j.memory.chat.ChatMemoryProvider; import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.moderation.ModerationModel; import dev.langchain4j.rag.RetrievalAugmentor; import dev.langchain4j.service.AiServices; import dev.langchain4j.service.tool.ToolArgumentsErrorHandler; @@ -58,6 +59,7 @@ *
  • Retrieval Augmentor: For RAG (Retrieval-Augmented Generation) capabilities
  • *
  • Input Guardrails: Security filters applied to incoming messages
  • *
  • Output Guardrails: Security filters applied to agent responses
  • + *
  • Moderation Model: Pre-moderation of user message text before the chat model is invoked
  • *
  • Custom Tools: Custom LangChain4j tools with @Tool annotations
  • *
  • MCP Clients: Model Context Protocol clients for external tool integration
  • *
  • MCP Tool Filters: Filters for controlling which MCP tools are available
  • @@ -69,6 +71,7 @@ public class AgentConfiguration { private static final Logger LOG = LoggerFactory.getLogger(AgentConfiguration.class); private ChatModel chatModel; + private ModerationModel moderationModel; private ChatMemoryProvider chatMemoryProvider; private RetrievalAugmentor retrievalAugmentor; private List> inputGuardrailClasses; @@ -105,6 +108,30 @@ public AgentConfiguration withChatModel(ChatModel chatModel) { return this; } + /** + * Gets the configured moderation model. + * + * @return the moderation model instance, or {@code null} if not configured + * @since 4.23 + */ + public ModerationModel getModerationModel() { + return moderationModel; + } + + /** + * Sets the LangChain4j moderation model for content policy checks on agent user messages. When configured, flagged + * user input raises {@code ModerationException} before the chat model is invoked, so tools and memory are not + * updated for rejected input. + * + * @param moderationModel the moderation model (for example OpenAI or Mistral moderation APIs) + * @return this configuration instance for method chaining + * @since 4.23 + */ + public AgentConfiguration withModerationModel(ModerationModel moderationModel) { + this.moderationModel = moderationModel; + return this; + } + /** * Gets the configured chat memory provider. * @@ -539,6 +566,7 @@ public AgentConfiguration withExecuteToolsConcurrentlyEnabled(Boolean executeToo public AgentConfiguration duplicate() { AgentConfiguration copy = new AgentConfiguration(); copy.chatModel = chatModel; + copy.moderationModel = moderationModel; copy.chatMemoryProvider = chatMemoryProvider; copy.retrievalAugmentor = retrievalAugmentor; copy.inputGuardrailClasses = inputGuardrailClasses; diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java index 62a46b775f698..a14a13d0a98e2 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithMemory.java @@ -36,6 +36,8 @@ public AgentWithMemory(AgentConfiguration configuration) { @Override public Result chat(AiAgentBody aiAgentBody, ToolProvider toolProvider) { + ModerationSupport.moderateUserMessage(configuration.getModerationModel(), aiAgentBody.getUserMessage()); + AiAgentWithMemoryService agentService = createAiAgentService(toolProvider); String userMessage = aiAgentBody.getUserMessage(); diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java index c2b0279cedd5d..7970237d3276c 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AgentWithoutMemory.java @@ -36,6 +36,8 @@ public AgentWithoutMemory(AgentConfiguration configuration) { @Override public Result chat(AiAgentBody aiAgentBody, ToolProvider toolProvider) { + ModerationSupport.moderateUserMessage(configuration.getModerationModel(), aiAgentBody.getUserMessage()); + AiAgentWithoutMemoryService agentService = createAiAgentService(toolProvider); String userMessage = aiAgentBody.getUserMessage(); diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java index 171a9dde6f95a..067fbad76124c 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java @@ -71,4 +71,8 @@ public class Headers { @Metadata(description = "Tool executions performed during agent invocation.", javaType = "java.util.List") public static final String TOOL_EXECUTIONS = "CamelLangChain4jAgentToolExecutions"; + + @Metadata(description = "Set to true when user input is rejected by the configured moderation model; not set on success.", + javaType = "Boolean") + public static final String MODERATION_FLAGGED = "CamelLangChain4jAgentModerationFlagged"; } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java new file mode 100644 index 0000000000000..1999f64de2100 --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/ModerationSupport.java @@ -0,0 +1,69 @@ +/* + * 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.camel.component.langchain4j.agent.api; + +import dev.langchain4j.model.moderation.Moderation; +import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.model.output.Response; +import dev.langchain4j.service.ModerationException; +import org.apache.camel.util.ObjectHelper; + +/** + * Pre-invocation content moderation for agent chat requests. + *

    + * Camel runs moderation before building the LangChain4j AI service call so flagged user input is rejected before the + * chat model, tools, or memory are updated. Custom {@link Agent} implementations should call + * {@link #moderateUserMessage(ModerationModel, String)} with the same semantics when they bypass + * {@link AgentWithMemory} or {@link AgentWithoutMemory}. + * + * @since 4.23 + */ +public final class ModerationSupport { + + private ModerationSupport() { + } + + /** + * Moderates the user message when a {@link ModerationModel} is configured. + *

    + * Only non-empty user message text is moderated. Multimodal {@code Content} without a user message is not sent to + * the moderation model. When a moderation model is configured, a missing verdict fails closed and raises + * {@link ModerationException} without marking the input as flagged. Provider infrastructure failures (timeouts, + * rate limits, auth errors) are rethrown so callers can classify and retry them appropriately. + * + * @param moderationModel the moderation model, may be {@code null} + * @param userMessage the user message to check + * @throws ModerationException when the moderation model flags the input or returns no verdict + */ + public static void moderateUserMessage(ModerationModel moderationModel, String userMessage) { + if (moderationModel == null || ObjectHelper.isEmpty(userMessage)) { + return; + } + + Response response = moderationModel.moderate(userMessage); + if (response == null || response.content() == null) { + throw new ModerationException( + "Moderation model returned no verdict for user message", + Moderation.notFlagged()); + } + + Moderation moderation = response.content(); + if (moderation.flagged()) { + throw new ModerationException("User message flagged by moderation model", moderation); + } + } +} diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java index d75633725beb0..3981e9625c93d 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentConfigurationTest.java @@ -32,6 +32,7 @@ import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.moderation.ModerationModel; import dev.langchain4j.service.tool.ToolArgumentsErrorHandler; import dev.langchain4j.service.tool.ToolExecutionErrorHandler; import org.junit.jupiter.api.Test; @@ -342,6 +343,16 @@ public void testAiServicesCustomizer() { assertNotNull(config.getAiServicesCustomizer()); } + @Test + void testWithModerationModel() { + ModerationModel moderationModel = new FlaggingModerationModel("never-used"); + + AgentConfiguration config = new AgentConfiguration() + .withModerationModel(moderationModel); + + assertSame(moderationModel, config.getModerationModel()); + } + @Test void duplicateCopiesAllDeclaredInstanceFields() throws Exception { Executor executor = Executors.newSingleThreadExecutor(); @@ -359,6 +370,7 @@ public ChatResponse doChat(ChatRequest request) { AgentConfiguration original = new AgentConfiguration() .withChatModel(chatModel) + .withModerationModel(new FlaggingModerationModel("never-used")) .withMaxToolCallingRoundTrips(11) .withHallucinatedToolNameStrategy(strategy) .withToolExecutionErrorHandler(execHandler) diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java new file mode 100644 index 0000000000000..1277520cf806f --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/AgentModerationTest.java @@ -0,0 +1,248 @@ +/* + * 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.camel.component.langchain4j.agent.api; + +import java.util.concurrent.atomic.AtomicInteger; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.memory.chat.ChatMemoryProvider; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.moderation.Moderation; +import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.model.moderation.ModerationRequest; +import dev.langchain4j.model.moderation.ModerationResponse; +import dev.langchain4j.model.output.Response; +import dev.langchain4j.service.ModerationException; +import dev.langchain4j.service.Result; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AgentModerationTest { + + private static final String FLAGGED_TOKEN = "policy-violation"; + + @Test + void agentWithoutMemoryAllowsCleanInputWhenModerationConfigured() { + Agent agent = new AgentWithoutMemory(moderatedConfiguration(countingChatModel(new AtomicInteger()))); + + Result result = agent.chat(new AiAgentBody<>("Hello support team"), null); + + assertThat(result.content()).isEqualTo("ok"); + } + + @Test + void agentWithoutMemoryRejectsFlaggedInputWhenModerationConfigured() { + Agent agent = new AgentWithoutMemory(moderatedConfiguration(countingChatModel(new AtomicInteger()))); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN), null)) + .isInstanceOf(ModerationException.class) + .satisfies(error -> { + ModerationException moderationException = (ModerationException) error; + assertThat(moderationException.moderation()).isNotNull(); + assertThat(moderationException.moderation().flagged()).isTrue(); + }); + } + + @Test + void agentWithoutMemoryDoesNotInvokeChatModelWhenInputIsFlagged() { + AtomicInteger chatInvocations = new AtomicInteger(); + Agent agent = new AgentWithoutMemory(moderatedConfiguration(countingChatModel(chatInvocations))); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN), null)) + .isInstanceOf(ModerationException.class); + + assertThat(chatInvocations.get()).isZero(); + } + + @Test + void agentWithoutMemorySkipsModerationWhenModelNotConfigured() { + AgentConfiguration configuration = new AgentConfiguration().withChatModel(noopChatModel()); + Agent agent = new AgentWithoutMemory(configuration); + + Result result = agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN), null); + + assertThat(result.content()).isEqualTo("ok"); + } + + @Test + void agentWithMemoryRejectsFlaggedInputWhenModerationConfigured() { + ChatMemoryProvider memoryProvider = memoryId -> MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .build(); + + AgentConfiguration configuration = moderatedConfiguration(countingChatModel(new AtomicInteger())) + .withChatMemoryProvider(memoryProvider); + Agent agent = new AgentWithMemory(configuration); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN, null, "session-1"), null)) + .isInstanceOf(ModerationException.class); + } + + @Test + void agentWithMemoryDoesNotInvokeChatModelWhenInputIsFlagged() { + AtomicInteger chatInvocations = new AtomicInteger(); + ChatMemoryProvider memoryProvider = memoryId -> MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .build(); + + AgentConfiguration configuration = moderatedConfiguration(countingChatModel(chatInvocations)) + .withChatMemoryProvider(memoryProvider); + Agent agent = new AgentWithMemory(configuration); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN, null, "session-1"), null)) + .isInstanceOf(ModerationException.class); + + assertThat(chatInvocations.get()).isZero(); + } + + @Test + void agentWithMemoryAllowsCleanInputWhenModerationConfigured() { + ChatMemoryProvider memoryProvider = memoryId -> MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .build(); + + AgentConfiguration configuration = moderatedConfiguration(countingChatModel(new AtomicInteger())) + .withChatMemoryProvider(memoryProvider); + Agent agent = new AgentWithMemory(configuration); + + Result result = agent.chat(new AiAgentBody<>("Hello", null, "session-2"), null); + + assertThat(result.content()).isEqualTo("ok"); + } + + @Test + void moderationSupportFailsClosedWhenModelReturnsNoVerdict() { + ModerationModel moderationModel = new ModerationModel() { + @Override + public Response moderate(String text) { + return null; + } + }; + + assertThatThrownBy(() -> ModerationSupport.moderateUserMessage(moderationModel, "hello")) + .isInstanceOf(ModerationException.class) + .satisfies(error -> { + ModerationException moderationException = (ModerationException) error; + assertThat(moderationException.moderation()).isNotNull(); + assertThat(moderationException.moderation().flagged()).isFalse(); + }); + } + + @Test + void moderationSupportRethrowsProviderFailures() { + RuntimeException providerFailure = new RuntimeException("moderation API unavailable"); + ModerationModel moderationModel = new ModerationModel() { + @Override + public ModerationResponse doModerate(ModerationRequest request) { + throw providerFailure; + } + }; + + assertThatThrownBy(() -> ModerationSupport.moderateUserMessage(moderationModel, "hello")) + .isSameAs(providerFailure); + } + + @Test + void agentWithoutMemoryRethrowsModerationProviderFailures() { + RuntimeException providerFailure = new RuntimeException("moderation API unavailable"); + AgentConfiguration configuration = new AgentConfiguration() + .withChatModel(countingChatModel(new AtomicInteger())) + .withModerationModel(new ModerationModel() { + @Override + public ModerationResponse doModerate(ModerationRequest request) { + throw providerFailure; + } + }); + Agent agent = new AgentWithoutMemory(configuration); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("hello"), null)) + .isSameAs(providerFailure); + } + + @Test + void agentWithMemoryDoesNotAccessMemoryProviderWhenInputIsFlagged() { + AtomicInteger memoryProviderInvocations = new AtomicInteger(); + ChatMemoryProvider memoryProvider = memoryId -> { + memoryProviderInvocations.incrementAndGet(); + return MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .build(); + }; + + AgentConfiguration configuration = moderatedConfiguration(countingChatModel(new AtomicInteger())) + .withChatMemoryProvider(memoryProvider); + Agent agent = new AgentWithMemory(configuration); + + assertThatThrownBy(() -> agent.chat(new AiAgentBody<>("message with " + FLAGGED_TOKEN, null, "session-1"), null)) + .isInstanceOf(ModerationException.class); + + assertThat(memoryProviderInvocations.get()).isZero(); + } + + @Test + void moderationSupportSkipsEmptyUserMessage() { + ModerationModel moderationModel = new FlaggingModerationModel(FLAGGED_TOKEN); + + ModerationSupport.moderateUserMessage(moderationModel, null); + ModerationSupport.moderateUserMessage(moderationModel, ""); + } + + @Test + void moderationSupportThrowsWhenModelFlagsInput() { + ModerationModel moderationModel = new FlaggingModerationModel(FLAGGED_TOKEN); + + assertThatThrownBy(() -> ModerationSupport.moderateUserMessage(moderationModel, "contains " + FLAGGED_TOKEN)) + .isInstanceOf(ModerationException.class) + .extracting(error -> ((ModerationException) error).moderation()) + .isNotNull() + .extracting(Moderation::flagged) + .isEqualTo(true); + } + + private static AgentConfiguration moderatedConfiguration(ChatModel chatModel) { + return new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(flaggingModerationModel()); + } + + private static ChatModel countingChatModel(AtomicInteger counter) { + return new ChatModel() { + @Override + public ChatResponse doChat(ChatRequest request) { + counter.incrementAndGet(); + return ChatResponse.builder().aiMessage(AiMessage.from("ok")).build(); + } + }; + } + + private static ChatModel noopChatModel() { + return countingChatModel(new AtomicInteger()); + } + + private static ModerationModel flaggingModerationModel() { + return new FlaggingModerationModel(FLAGGED_TOKEN); + } +} diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/FlaggingModerationModel.java b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/FlaggingModerationModel.java new file mode 100644 index 0000000000000..e33e406151788 --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent-api/src/test/java/org/apache/camel/component/langchain4j/agent/api/FlaggingModerationModel.java @@ -0,0 +1,40 @@ +/* + * 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.camel.component.langchain4j.agent.api; + +import dev.langchain4j.model.moderation.Moderation; +import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.model.moderation.ModerationRequest; +import dev.langchain4j.model.moderation.ModerationResponse; + +final class FlaggingModerationModel implements ModerationModel { + + private final String flaggedToken; + + FlaggingModerationModel(String flaggedToken) { + this.flaggedToken = flaggedToken; + } + + @Override + public ModerationResponse doModerate(ModerationRequest request) { + String text = request.texts() == null || request.texts().isEmpty() ? "" : request.texts().get(0); + boolean flagged = text.contains(flaggedToken); + return ModerationResponse.builder() + .moderation(flagged ? Moderation.flagged(text) : Moderation.notFlagged()) + .build(); + } +} diff --git a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json index 82fbe66b7ed2e..bd4b5b9e9ad7b 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json +++ b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json @@ -53,7 +53,8 @@ "CamelLangChain4jAgentRequestModel": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#REQUEST_MODEL" }, "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name. Not set by the agent producer when langchain4j Result does not expose it.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, - "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" } + "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" }, + "CamelLangChain4jAgentModerationFlagged": { "index": 14, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Set to true when user input is rejected by the configured moderation model; not set on success.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#MODERATION_FLAGGED" } }, "properties": { "agentId": { "index": 0, "kind": "path", "displayName": "Agent Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Agent id" }, diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc index 5d2fd309ffa91..71e28b536f502 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc +++ b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc @@ -158,6 +158,7 @@ Agents are configured using the `AgentConfiguration` class which provides a flue * Chat Memory Provider (for memory-enabled agents) * Retrieval Augmentor (for RAG functionality) * Input and Output Guardrails +* Content moderation (`withModerationModel`) * Concurrent tool execution (`withExecuteToolsConcurrently`) for parallel Camel route tools and MCP tools within one LLM round trip * Tool-calling control: round-trip limits, hallucinated tool handling, and error compensation * AiServices builder customizer for advanced LangChain4j options @@ -178,6 +179,28 @@ Agents are configured using the `AgentConfiguration` class which provides a flue | `withExecuteToolsConcurrently()` / `withExecuteToolsConcurrently(Executor)` | Runs multiple tool calls from one LLM turn in parallel |=== +==== Content moderation + +When `AgentConfiguration.withModerationModel(ModerationModel)` is set, Camel pre-moderates the user message before invoking the chat model. Flagged input raises `ModerationException` before tools run or memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` on failure so routes can return a controlled refusal. See xref:others:langchain4j-agent-guardrails.adoc#_content_moderation[Content Moderation] for route examples. The flagged text is available via `${exception.moderation}` when handling `ModerationException`. + +._Java-only: public support chatbot with moderation_ +[source,java] +---- +AgentConfiguration configuration = new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(openAiModerationModel); + +context.getRegistry().bind("supportAgentConfig", configuration); + +onException(ModerationException.class) + .handled(true) + .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(422)) + .setBody(constant("Sorry, your message violates our usage policy.")); + +from("platform-http:/support/chat") + .to("langchain4j-agent:support?agentConfiguration=#supportAgentConfig&tags=orders,billing"); +---- + ._Java-only: recover from a hallucinated tool name_ [source,java] ---- diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc index c79d94ec21ba5..41244944ffb10 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc +++ b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-guardrails.adoc @@ -610,12 +610,44 @@ public class AgentConfig { } ---- +=== Content Moderation + +Configure a LangChain4j `ModerationModel` on `AgentConfiguration.withModerationModel(...)`. Camel pre-moderates the user message before the chat model is invoked, so flagged input raises `ModerationException` before tools run or conversation memory is updated. The producer sets the `CamelLangChain4jAgentModerationFlagged` header to `true` when moderation flags the input so routes can return a controlled refusal. Provider infrastructure failures are rethrown without setting the flagged header. + +Moderation runs before LangChain4j input guardrails, which execute inside the AiServices call. Moderation complements guardrails: guardrails are deterministic Java rules you implement; moderation delegates policy checks to a provider API (OpenAI Moderation, Mistral moderation, etc.). Only the user message text is moderated; system prompts configured by the route author are not sent to the moderation model. + +Custom `Agent` implementations should call `ModerationSupport.moderateUserMessage(...)` when they bypass `AgentWithMemory` / `AgentWithoutMemory`. Moderation failures are policy rejections (`GenAiErrorCategory.VALIDATION`), not malformed requests to retry blindly. + +._Java-only: public support chatbot with moderation and controlled refusal_ +[source,java] +---- +AgentConfiguration configuration = new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(openAiModerationModel); + +context.getRegistry().bind("supportAgentConfig", configuration); + +onException(ModerationException.class) + .handled(true) + .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(422)) + .setBody(constant("Sorry, your message violates our usage policy.")); + +from("platform-http:/support/chat") + .to("langchain4j-agent:support?agentConfiguration=#supportAgentConfig&tags=orders,billing"); +---- + +In Spring Boot, inject an auto-configured `ModerationModel` bean from the LangChain4j starter alongside `ChatModel`. + === Guardrail Behavior Summary [cols="1,1,2"] |=== |Guardrail |When Triggered |Result +|`Content moderation` +|User message flagged by moderation model +|Blocks the request before input guardrails and chat model + |`InputLengthGuardrail` |Input too short/long |Blocks the request diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java index 5dbd4b6fecc14..d82c6da90bb7b 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java +++ b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java @@ -40,6 +40,7 @@ import dev.langchain4j.model.chat.request.ResponseFormatType; import dev.langchain4j.model.chat.request.json.JsonRawSchema; import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.service.ModerationException; import dev.langchain4j.service.Result; import dev.langchain4j.service.output.JsonSchemas; import dev.langchain4j.service.tool.ToolExecutor; @@ -198,6 +199,7 @@ public void process(Exchange exchange) throws Exception { result.finishReason(), null)); } catch (RuntimeException e) { + applyModerationHeaders(exchange, e); GenAiErrorSupport.apply(exchange, e); observation.recordError(e); throw e; @@ -206,6 +208,19 @@ public void process(Exchange exchange) throws Exception { } } + private void applyModerationHeaders(Exchange exchange, RuntimeException error) { + Throwable current = error; + while (current != null) { + if (current instanceof ModerationException moderationException) { + if (moderationException.moderation() != null && moderationException.moderation().flagged()) { + exchange.getMessage().setHeader(Headers.MODERATION_FLAGGED, Boolean.TRUE); + } + return; + } + current = current.getCause(); + } + } + private Object resolveChatModel(Agent agent) { if (endpoint.getConfiguration().getAgentConfiguration() != null) { return endpoint.getConfiguration().getAgentConfiguration().getChatModel(); diff --git a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/FlaggingModerationModel.java b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/FlaggingModerationModel.java new file mode 100644 index 0000000000000..9f527e2ea247e --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/FlaggingModerationModel.java @@ -0,0 +1,40 @@ +/* + * 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.camel.component.langchain4j.agent; + +import dev.langchain4j.model.moderation.Moderation; +import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.model.moderation.ModerationRequest; +import dev.langchain4j.model.moderation.ModerationResponse; + +final class FlaggingModerationModel implements ModerationModel { + + private final String flaggedToken; + + FlaggingModerationModel(String flaggedToken) { + this.flaggedToken = flaggedToken; + } + + @Override + public ModerationResponse doModerate(ModerationRequest request) { + String text = request.texts() == null || request.texts().isEmpty() ? "" : request.texts().get(0); + boolean flagged = text.contains(flaggedToken); + return ModerationResponse.builder() + .moderation(flagged ? Moderation.flagged(text) : Moderation.notFlagged()) + .build(); + } +} diff --git a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java new file mode 100644 index 0000000000000..6943d288cfa0b --- /dev/null +++ b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentModerationTest.java @@ -0,0 +1,177 @@ +/* + * 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.camel.component.langchain4j.agent; + +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.memory.chat.ChatMemoryProvider; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.moderation.ModerationModel; +import dev.langchain4j.service.ModerationException; +import org.apache.camel.Exchange; +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.ai.observability.GenAiErrorCategory; +import org.apache.camel.component.ai.observability.GenAiErrorProperties; +import org.apache.camel.component.ai.observability.GenAiObservabilityProperties; +import org.apache.camel.component.langchain4j.agent.api.AgentConfiguration; +import org.apache.camel.component.langchain4j.agent.api.AiAgentBody; +import org.apache.camel.component.langchain4j.agent.api.Headers; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.spi.Registry; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LangChain4jAgentModerationTest extends CamelTestSupport { + + private static final String FLAGGED_TOKEN = "policy-violation"; + + private final AtomicReference moderatedExchange = new AtomicReference<>(); + private final AtomicInteger chatInvocations = new AtomicInteger(); + + @BeforeEach + void resetModeratedExchange() { + moderatedExchange.set(null); + chatInvocations.set(0); + } + + @Override + protected void bindToRegistry(Registry registry) { + registry.bind("moderatedAgentConfig", moderatedAgentConfiguration(chatInvocations)); + registry.bind("memoryModeratedAgentConfig", memoryModeratedAgentConfiguration(new AtomicInteger())); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + Properties properties = new Properties(); + properties.setProperty(GenAiObservabilityProperties.ENABLED, "false"); + context.getPropertiesComponent().setOverrideProperties(properties); + + onException(ModerationException.class) + .process(exchange -> moderatedExchange.set(exchange)) + .setBody(constant("Sorry, your message violates our usage policy.")) + .handled(true); + + from("direct:moderated") + .to("langchain4j-agent:support?agentConfiguration=#moderatedAgentConfig") + .to("mock:result"); + + from("direct:clean") + .to("langchain4j-agent:support?agentConfiguration=#moderatedAgentConfig") + .to("mock:success"); + + from("direct:memory-moderated") + .to("langchain4j-agent:support?agentConfiguration=#memoryModeratedAgentConfig") + .to("mock:memory-result"); + } + }; + } + + @Test + void shouldExposeModerationFlaggedHeaderWhenInputIsFlagged() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(0); + + template.sendBody("direct:moderated", new AiAgentBody<>("contains " + FLAGGED_TOKEN)); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + + Exchange exchange = moderatedExchange.get(); + assertThat(exchange).isNotNull(); + assertThat(exchange.getMessage().getBody(String.class)) + .isEqualTo("Sorry, your message violates our usage policy."); + assertThat(exchange.getMessage().getHeader(Headers.MODERATION_FLAGGED, Boolean.class)).isTrue(); + assertThat(exchange.getProperty(GenAiErrorProperties.ERROR_CATEGORY, String.class)) + .isEqualTo(GenAiErrorCategory.VALIDATION.name()); + } + + @Test + void shouldNotInvokeChatModelWhenInputIsFlagged() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(0); + + template.sendBody("direct:moderated", new AiAgentBody<>("contains " + FLAGGED_TOKEN)); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + + assertThat(chatInvocations.get()).isZero(); + } + + @Test + void shouldReturnResponseWhenInputPassesModeration() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:success"); + mock.expectedMessageCount(1); + mock.expectedBodiesReceived("ok"); + + template.sendBody("direct:clean", new AiAgentBody<>("Hello support")); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + } + + @Test + void shouldRejectFlaggedInputForMemoryAgentRoute() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:memory-result"); + mock.expectedMessageCount(0); + + template.sendBody("direct:memory-moderated", + new AiAgentBody<>("contains " + FLAGGED_TOKEN, null, "session-42")); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + + Exchange exchange = moderatedExchange.get(); + assertThat(exchange).isNotNull(); + assertThat(exchange.getMessage().getHeader(Headers.MODERATION_FLAGGED, Boolean.class)).isTrue(); + } + + private static AgentConfiguration moderatedAgentConfiguration(AtomicInteger chatInvocations) { + ChatModel chatModel = new ChatModel() { + @Override + public ChatResponse doChat(ChatRequest request) { + chatInvocations.incrementAndGet(); + return ChatResponse.builder().aiMessage(AiMessage.from("ok")).build(); + } + }; + + ModerationModel moderationModel = new FlaggingModerationModel(FLAGGED_TOKEN); + + return new AgentConfiguration() + .withChatModel(chatModel) + .withModerationModel(moderationModel); + } + + private static AgentConfiguration memoryModeratedAgentConfiguration(AtomicInteger chatInvocations) { + ChatMemoryProvider memoryProvider = memoryId -> MessageWindowChatMemory.builder() + .id(memoryId) + .maxMessages(10) + .build(); + + return moderatedAgentConfiguration(chatInvocations).withChatMemoryProvider(memoryProvider); + } +} diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java index 9acf041cfb2f7..64d267adea907 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java @@ -724,6 +724,20 @@ public String langChain4jAgentSources() { public String langChain4jAgentToolExecutions() { return "CamelLangChain4jAgentToolExecutions"; } + /** + * Set to true when user input is rejected by the configured moderation + * model; not set on success. + * + * The option is a: {@code Boolean} type. + * + * Group: producer + * + * @return the name of the header {@code + * LangChain4jAgentModerationFlagged}. + */ + public String langChain4jAgentModerationFlagged() { + return "CamelLangChain4jAgentModerationFlagged"; + } } static LangChain4jAgentEndpointBuilder endpointBuilder(String componentName, String path) { class LangChain4jAgentEndpointBuilderImpl extends AbstractEndpointBuilder implements LangChain4jAgentEndpointBuilder, AdvancedLangChain4jAgentEndpointBuilder {