Skip to content

CAMEL-23965: camel-openai - Add moderation operation - #25410

Open
k-krawczyk wants to merge 4 commits into
apache:mainfrom
k-krawczyk:CAMEL-23965-openai-moderation
Open

CAMEL-23965: camel-openai - Add moderation operation#25410
k-krawczyk wants to merge 4 commits into
apache:mainfrom
k-krawczyk:CAMEL-23965-openai-moderation

Conversation

@k-krawczyk

@k-krawczyk k-krawczyk commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an openai:moderation operation, as proposed in CAMEL-23965.

Moderation is the canonical pre-filter for untrusted input on a public-facing route: policy-violating content can be rejected before spending chat tokens or triggering tool calls.

  • The message body is passed through unchanged and the verdict is exposed as headers, so the result drives content-based routing while the original content stays available to the rest of the route.
  • A String body moderates one input; a List body moderates the whole batch in a single call.
  • CamelOpenAIModerationFlagged is always a Boolean — for a batch it is true when at least one input was flagged — so the same when(header(...).isEqualTo(true)) works for both shapes. If the provider returns fewer results than inputs, the exchange fails with a CamelExchangeException rather than leaving the flag false and letting the message through. CamelOpenAIModerationCategories / ...CategoryScores follow the body shape: a Map for a String body and a List of maps for a List body, including a single-element list, so batch processors need no special case.
  • The model comes from the new moderationModel option (default omni-moderation-latest, so the operation works with no configuration) or the CamelOpenAIModerationModel header.
  • With storeFullResponse=true the SDK response is stored in the CamelOpenAIModerationResponse exchange property. This is a dedicated property rather than the existing CamelOpenAIResponse, whose declared javaType is ChatCompletion — same reasoning as CamelOpenAIResponsesResponse.

Notes for reviewers

  • SDK version: the issue was written against openai-java 4.41.0; parent/pom.xml is now on 4.49.0. The API used here was verified against the 4.49.0 artifact.
  • illicit categories: Moderation.Categories.illicit() / illicitViolent() are Optional because only the omni-moderation-* models return them, while the same categories are plain double in CategoryScores. The category map therefore omits them for legacy models. This is documented and covered by a test.
  • Category names are mapped explicitly to the names the API returns (hate/threatening, self-harm/intent, ...) rather than relying on SDK serialisation.
  • No upgrade guide entry: this is a new feature, and per CLAUDE.md the upgrade guide is for migration only.

Compatibility note

camel-test-infra-openai-mock is a released artifact: OpenAIMockExpectations gained a component and the OpenAIMockBuilder constructor gained a parameter. Nothing in this repository constructs either directly, and the two consuming modules (camel-langchain4j-agent, camel-langchain4j-tools) use the fluent API and are covered by the verification below, but a downstream project constructing them directly needs a one-line update.

Test infra

camel-test-infra-openai-mock gains support for the /moderations endpoint (whenModeration(...) with replyWithModerationAllowed(), replyWithModerationFlagged(category, score), replyWithModerationScore(...) and replyWithoutIllicitCategories()), so the operation is covered without calling a live API. The mock now also echoes back the model from the request, as the real API does.

Verification

  • mvn install -Psourcecheck -DskipITs on camel-openai, camel-test-infra-openai-mock, camel-langchain4j-agent and camel-langchain4j-tools: all green (196 + 49 + 42 tests, 0 failures). The two LangChain4j modules are included because they consume the mock, whose builder signature changed.
  • 10 test cases for the new operation: allowed input, flagged input, scored-but-not-flagged, batch, storeFullResponse (asserting the stored response is usable, not just deserialisable), model resolution (default / endpoint option / header), a legacy model without the illicit categories, a non-String body, a missing body, and the guard route from the issue.
  • Generated catalog metadata and configurers regenerated and committed.
  • No integration test: the ITs in this component run against Ollama via testcontainers, and Ollama does not expose /v1/moderations, so an IT in the module's existing style could not pass.

Reported by Claude Code on behalf of Karol Krawczyk

k-krawczyk and others added 2 commits August 8, 2026 23:17
Add an openai:moderation operation that checks text against the OpenAI usage
policies through the SDK moderations API. It is meant as a pre-filter for
untrusted input on public-facing routes, so policy-violating content can be
rejected before spending chat tokens or triggering tool calls.

The message body is passed through unchanged and the verdict is exposed as
headers, which keeps the original content available to the rest of the route
and allows content-based routing on CamelOpenAIModerationFlagged. A String
body moderates one input, a List body moderates the whole batch in a single
call; for a batch the flag is true when at least one input was flagged and the
category headers hold one map per input.

The model comes from the new moderationModel option (default
omni-moderation-latest) or the CamelOpenAIModerationModel header, and the full
SDK response is available as the CamelOpenAIModerationResponse exchange
property when storeFullResponse is enabled.

camel-test-infra-openai-mock gains support for the /moderations endpoint, with
whenModeration/replyWithModerationAllowed/replyWithModerationFlagged builder
methods, so the operation can be covered without calling the live API.

Co-authored-by: Claude <noreply@anthropic.com>
Verified the operation against the live OpenAI moderation API and corrected what did
not match.

The mock now also returns category_applied_input_types, which every real response
carries, so the stored SDK response is complete; the mock-based test asserts
isValid() on it to keep the two from drifting apart.

The tests used text-moderation-stable as an example model name, but the legacy
text-moderation-* models no longer exist - the API rejects them - so they now use the
omni-moderation models that are actually served. For the same reason the note about
the optional illicit categories no longer refers to older OpenAI models; they are
optional because an OpenAI-compatible provider may not implement them.

Co-authored-by: Claude <noreply@anthropic.com>
@atiaomar1978-hub

Copy link
Copy Markdown
Contributor

Code review (Bugbot + Grok)

AI-generated review on behalf of @atiaomar1978-hub

Thanks for the thorough PR — the moderation operation design is solid and aligns well with the embeddings producer pattern. Mock infra, docs, and test breadth are strong. A few items to address before merge:


Should fix

1. Empty moderation results fail open (Bugbot — high)

In OpenAIModerationProducer.setResponseHeaders, CamelOpenAIModerationFlagged is derived from results.stream().anyMatch(Moderation::flagged). If the API returns an empty results list (or a count mismatch vs. submitted inputs), this yields false and guard routes like when(header(CamelOpenAIModerationFlagged).isEqualTo(true)) will allow the message through even though no verdict was returned.

Suggestion: Reject empty/mismatched results with a clear exception, or treat as flagged / fail the exchange — but don't silently pass.


2. Singleton List body vs. documented header shape (Grok — important)

Header unwrapping is keyed on inputCount == 1, so List.of("only-one") produces Map headers, while the batch docs say a List body yields List<Map<...>> and show:

List<Map<String, Boolean>> categories =
    exchange.getMessage().getHeader(..., List.class);

That is a runtime footgun for generic batch processors.

Suggestion: Unwrap based on original body type (StringMap; any ListList of maps), or document explicitly that only size > 1 lists get list headers — and add a test for List size 1.


3. storeFullResponse metadata incomplete (Bugbot — medium)

Moderation stores the full SDK response in exchange property CamelOpenAIModerationResponse, but the shared @Metadata on storeFullResponse in OpenAIConfiguration still documents only CamelOpenAIResponse / CamelOpenAIResponsesResponse. Please extend the description so catalog/component metadata users find the right property.


Test gaps

  • List of size 1 → assert header runtime type (Map vs List) once contract is decided
  • Empty List body → expect same rejection as null/missing body
  • Non-String body pass-through (e.g. Integer) — only flagged path is covered today

Nits / follow-ups

  • List containing null elements → String.valueOf(null) moderates the literal "null"; worth documenting or rejecting
  • Docs list “moderation model missing” alongside required audio models, but moderationModel defaults to omni-moderation-latest — failure mode is overstated
  • OpenAIMockExpectations / builder ctor change is a compile-time break for direct test-infra callers — one-line note in PR/release notes would help
  • JIRA CAMEL-23965 still shows Unassigned while PR is open — process hygiene

Security

No trust-boundary issues found — moderation input is route-author supplied (trusted), API key handling unchanged, no unsafe deserialization.


Verdict

Request changes on items 1–3 and the test gaps above. Happy to re-review once CI is green.

Overall this is a valuable addition for public-facing AI routes — nice work on the mock infra and the pass-through + header routing design.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

Fail closed when no verdict comes back. CamelOpenAIModerationFlagged was derived with
anyMatch over the results, so a provider returning fewer results than inputs - an empty
list in the worst case - left the flag false and a guard route let the message through.
A result count that does not match the input count now raises a CamelExchangeException.

Shape the category headers after the body rather than the input count. A single element
List used to produce Map headers, contradicting the documented batch contract and
breaking generic batch processors. A List body now always yields List headers.

Reject null elements in the input list instead of moderating the literal "null", and
extend the storeFullResponse option description with the moderation property.

Tests cover the missing verdict through both a plain route and the guard route, the
single element List, a String body, an empty list and a list with a null element. The
mock can now omit a result so the fail-closed path is actually exercised.

Co-authored-by: Claude <noreply@anthropic.com>
@k-krawczyk

Copy link
Copy Markdown
Contributor Author

Thanks @atiaomar1978-hub — a genuinely useful review. All three "should fix" items and the nits are addressed in 5cdf245 and 1c8641a.

1. Empty results fail open. You were right, and this was the serious one: for an operation whose only job is gating untrusted content, silently passing the message through is the worst possible failure mode. A result count that does not match the input count now raises a CamelExchangeException.

I did not take the "treat as flagged" option: marking content as violating when no verdict came back asserts something we do not know, and would show up as an unexplainable false positive. Failing the exchange states the truth — there is no verdict.

To prove the behaviour rather than just claim it, the mock can now omit a result (replyWithoutModerationResult()), and two tests cover it: one through a plain route, one through the guard route asserting the message is not accepted.

2. Singleton List vs documented shape. Also right. The header shape now follows the body shape rather than the input count: a String body yields Map headers, any List body yields List headers, including a single-element one. Tests assert the runtime type for both shapes, and the batch docs state the contract explicitly.

3. storeFullResponse metadata. Extended with CamelOpenAIModerationResponse.

Nits.

  • A null element in the input list is now rejected instead of moderating the literal "null".
  • The error handling docs no longer list a missing moderation model next to the required audio models, since moderationModel defaults to omni-moderation-latest. The defensive check stays for an explicitly empty value.
  • The empty-list case now raises the same IllegalArgumentException as a missing body, and the non-String body test asserts the body is passed through untouched.
  • Test infra compile break — good catch, calling it out here: OpenAIMockExpectations gained a component and the OpenAIMockBuilder constructor gained a parameter. Nothing in the repository calls either directly (camel-langchain4j-agent and camel-langchain4j-tools use the fluent API), and both modules are built and tested as part of this change, but a downstream project constructing them directly would need a one-line update.
  • JIRA is now assigned and In Progress.

Separately, I verified the operation against the live moderation API (5cdf245), which corrected two things a mock alone could never have caught:

  • Every real response carries category_applied_input_types, which the mock did not emit. Moderation.categoryAppliedInputTypes() is non-optional, so a stored response coming from the mock would have thrown where the real one works. The mock now emits it, and the test asserts isValid() on the stored response so the two cannot drift apart again.
  • The legacy text-moderation-* models no longer exist — the API rejects them with a 400 — so the tests use the omni-moderation models that are actually served, and the note about the optional illicit categories now attributes them to OpenAI-compatible providers rather than older OpenAI models.

The live run also confirmed the batch ordering, the 13 category names (independently cross-checked against the constants in the SDK deserialiser) and the guard route end to end. That test was local and temporary — it is not in this PR, because the component's ITs run against Ollama, which does not expose /v1/moderations.

Current state: 16 test cases for the operation; mvn install -Psourcecheck -DskipITs green across camel-openai, camel-test-infra-openai-mock, camel-langchain4j-agent and camel-langchain4j-tools (202 + 49 + 42 tests).

Reported by Claude Code on behalf of Karol Krawczyk

CI caught these as uncommitted generated changes: the endpoint DSL builders pick up the
new moderation operation, the moderationModel option and the moderation headers, and the
others navigation follows the renamed operations page title.

Co-authored-by: Claude <noreply@anthropic.com>
@atiaomar1978-hub

Copy link
Copy Markdown
Contributor

Bugbot & Grok Review Report (re-review)

AI-generated review on behalf of the operator.

PR: CAMEL-23965openai:moderation operation
Verdict: Approve with follow-ups — design is sound, fail-closed behaviour is correct, test coverage is strong. No merge-blocking correctness bugs found on current HEAD.


Executive summary

This PR adds a well-designed moderation gate: body passthrough, explicit category name mapping, aggregate CamelOpenAIModerationFlagged for simple choice guards, fail-closed on provider result count mismatch, and a solid mock test infra extension. Prior review concerns (fail-open on empty results, singleton-List header shape) are addressed in the current changeset.

16 unit tests in OpenAIModerationMockTest (PR body still says 10 — minor doc fix).


Bugbot findings

Must-fix

None for merge-blocking correctness.

Should-fix

# File Finding
1 OpenAIEndpoint.java:87-88 @UriPath operation @Metadata description still ends at 'audio-speech' — omits 'moderation'. Regenerated catalog inherits the stale string.
2 OpenAIModerationProducer.java:93-104 storeFullResponse sets CamelOpenAIModerationResponse before result-count validation. On size mismatch the exchange fails but the property may already be set. Prefer set-after-validate.
3 OpenAIModerationProducer.java:121-125 Non-String list elements are coerced via String.valueOf(item). For a content gate, rejecting non-String elements (like null, which is already rejected) avoids silently moderating toString() of structured/sensitive objects.
4 OpenAIModerationProducer.java:138-156 Batch mode exposes only an aggregate Boolean flagged header plus category/score lists — no per-item List<Boolean>. Per-item verdict requires scanning category maps or using storeFullResponse. Document this or expose per-item flags.
5 Upgrade guide camel-test-infra-openai-mock SPI break (OpenAIMockBuilder ctor, OpenAIMockExpectations component) is noted in the PR but missing from camel-4x-upgrade-guide-4_22.adoc. Project rules require upgrade-guide entries for API/SPI signature changes on released artifacts.
6 openai-operations.adoc:173-174 Docs describe moderation as the "canonical pre-filter for untrusted" input. Useful policy filter, but not a trust-boundary guarantee (probabilistic, provider-defined). Add a NOTE: not a substitute for authz, schema validation, or prompt-injection defences; flagged body still flows unless the route stops/replaces it.
7 OpenAIModerationProducer.java:84-87 Operation is text-only (input / inputOfStrings). SDK supports multimodal moderation; state this limitation explicitly in docs.

Nice-to-have

# Finding
1 Assert illicit categories present on default omni path in testAllowedInput (legacy omission is tested; omni presence is not).
2 Add partial batch omission test (N inputs, M < N results) to lock fail-closed contract beyond single-input case.
3 Mock emits category_applied_input_types; headers do not expose it — fine for text-only, note for future multimodal.
4 Update PR Verification section: 16 test methods, not 10.
5 MODERATION_RESPONSE catalogued as header but stored as exchange property — align description with CamelOpenAIResponsesResponse pattern.

Grok findings

Design assessment

Area Assessment
Producer Matches embeddings async shape; sync completion appropriate for moderation latency.
Fail-closed response.results().size() != inputs.size()CamelExchangeException; headers not set; testMissingVerdictFailsClosed + testGuardRouteDoesNotLetContentThroughWithoutVerdict prove no accidental pass-through. ✅
Headers Body-shaped categories/scores (Map for String body, List for List body, incl. singleton list) is the right contract. ✅
Categories Explicit API names (hate/threatening, etc.); optional illicit* via Optional.ifPresent on booleans; scores always full set. ✅
Mock infra /moderations routing, fluent whenModeration*, model echo, replyWithoutModerationResult for fail-closed testing — strong alignment with live API. ✅
Security model Consistent with Camel trust model: route author configures the gate; body passthrough + route-level choice is correct. Header model override follows existing openai op pattern — consumer should strip internal headers from untrusted producers. ✅

What looks good

  • Body passthrough unchanged — original content available after verdict
  • Null list elements rejected (no moderating literal "null")
  • Legacy model without illicit categories handled and tested
  • storeFullResponse test asserts isValid() on SDK response, not just deserialisation
  • Guard route example from the issue is tested end-to-end
  • AssertJ throughout; package-private test class/methods
  • No Thread.sleep in tests

Test coverage matrix (OpenAIModerationMockTest — 16 tests)

Scenario Test
Allowed input testAllowedInput
Flagged input testFlaggedInput
Scored but not flagged testScoredButNotFlaggedInput
Batch (2 inputs) testBatchModeration
storeFullResponse testStoreFullResponse
Model: default / option / header testModelFromEndpointOptionAndHeader
Legacy model (no illicit) testProviderWithoutIllicitCategories
Non-String body coercion testNonStringBodyIsConverted
Single-element List → List headers testSingleElementListKeepsTheBatchShape
String body → Map headers testStringBodyKeepsTheSingleShape
Missing verdict fail-closed testMissingVerdictFailsClosed
Guard route blocks missing verdict testGuardRouteDoesNotLetContentThroughWithoutVerdict
Empty list testEmptyListFails
Null list element testListWithNullElementFails
Missing body testMissingBodyFails
Guard route accepts/rejects testGuardRouteRejectsFlaggedInput

Recommended follow-up order

  1. Fix stale @UriPath operation description + regenerate catalog
  2. Add 4.22 upgrade-guide entry for camel-test-infra-openai-mock SPI break
  3. Add security/limitations NOTE in moderation docs (text-only, not a trust-boundary guarantee)
  4. Consider set-after-validate for storeFullResponse; stricter list element typing

Bottom line: Ready to approve once follow-ups are tracked. Strongest follow-ups are the stale operation metadata and the upgrade-guide note for the mock SPI break.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants