OpenAI content-moderation provider adapter — implements
ALLM.ModerationAdapter against OpenAI's POST /v1/moderations endpoint.
Layer B — runtime. Constructed via
ALLM.Engine.new(moderation_adapter: ALLM.Providers.OpenAI.Moderation) and
consumed through ALLM.moderate/3. Keys resolve via
ALLM.Keys.fetch!(:openai, opts) at request-build time — no key ever lives
on the engine.
req = ALLM.ModerationRequest.new(input: ["hello"], model: "omni-moderation-latest")
{:ok, resp} = ALLM.Providers.OpenAI.Moderation.moderate(req, api_key: "sk-...")
false = ALLM.ModerationResponse.flagged?(resp)Wire-field map
Every row below was observed live on 2026-08-31 by
scripts/record_openai_moderation_fixtures.exs, whose probe halts the
recording pass on a mismatch.
| Concern | OpenAI |
|---|---|
| Endpoint | POST https://api.openai.com/v1/moderations (not overridable) |
| Auth | authorization: Bearer <key> |
| Input (text) | input — always sent as an array, even for one string |
| Input (multimodal) | input — an array of content blocks: {"type":"text","text":…} and {"type":"image_url","image_url":{"url":…}}. Sent when ALLM.ModerationRequest.multimodal?/1 is true |
| Image source | a {:url, _} ALLM.Image forwards its URL verbatim; every other source is inlined as a data: URI |
detail | never sent. ALLM.ImagePart.detail is dropped with a one-per-process Logger.debug/1 — see below |
| Model | model — omitted when nil; OpenAI's own server default resolved to omni-moderation-latest |
| Options | ALLM.ModerationRequest.options is merged onto the body under the structural fields |
| Verdict | results[].flagged |
| Categories | results[].categories — 13 slash-named keys, provider-shaped and string-keyed |
| Scores | results[].category_scores — 13 float keys |
| Applied types | results[].category_applied_input_types → :applied_input_types (prefix dropped) |
| Index | absent from the wire — assigned from array position (invariant 4) |
| Usage | none. The endpoint is free and returns no usage object |
| Response id | top-level id ("modr-…"), one per HTTP call |
| Correlation | x-request-id response header |
| Error envelope | {"error": {"message", "type", "param", "code"}} |
| Batch cap | see max_batch_size/0 |
Models
omni-moderation-latest and omni-moderation-2024-09-26 only. The
text-moderation-* family was shut down on 2025-10-27 and answers a
400 ("Invalid value for 'model' = text-moderation-latest", recorded at
test/fixtures/openai/moderations/recorded/error_400_bad_model.json),
surfaced as %ALLM.Error.ModerationAdapterError{reason: :invalid_request}.
The adapter forwards whatever :model the caller sets rather than
maintaining a denylist that goes stale the moment a new model ships.
Adapter-injected defaults
None. A nil :model is OMITTED from the body, letting OpenAI apply its
own current default rather than pinning a name ALLM would have to chase.
ALLM.moderate/3 stamps the engine's resolved model onto the request before
dispatch, so a nil model only arises on a direct adapter call.
Batch size
max_batch_size/0 is 1000. This is a floor, not a
documented cap: OpenAI documents no maximum input array length, and the
ladder arm of the recorder (n ∈ [1, 32, 100, 128, 1000], run 2026-08-31)
found every rung accepted — no upper bound was observed. The number is the
ladder's top rung, chosen so the adapter never promises more than has been
demonstrated.
Unlike ALLM.embed/3, ALLM.moderate/3 does not chunk transparently: one
HTTP call yields exactly one provider id, and merging N chunks would produce
N ids with nowhere to put them. Callers who need more chunk with
Enum.chunk_every/2 against this function.
Unknown fields are IGNORED, not rejected
The recorder's negative-control arm found /v1/moderations returning 200
for an invented top-level field, unlike Voyage's embeddings endpoint or
OpenAI's own Chat Completions. Two consequences worth knowing:
- an unrecognised
ALLM.ModerationRequest.optionskey is silently dropped by the provider rather than surfacing a 400 — the caller gets a normal verdict and no signal that the knob did nothing; - "the API accepted it" is therefore not evidence that a field is part of this endpoint's schema.
Pre-flight gates
Before any HTTP I/O — and, deliberately, before ALLM.Keys.fetch!/2, so a
request that is going to be rejected never needs a valid API key:
Empty input.
input: []→:invalid_request(ALLM.ModerationAdapterinvariant 6).Batch size. item count
> max_batch_size/0→:batch_too_largewithmetadata: %{count: n, max: max}(invariant 5). The gate measures the item count invariant 3 defines, not the raw list length: an:inputcarrying anyALLM.ImagePartis exactly one multimodal item. An:inputthat is not a list at all is rejected here as:invalid_requestwithmetadata: %{field: :input}rather than raising.Images. Every
ALLM.ImagePartin:inputis validated byALLM.Providers.Support.ImageMime.validate/2againstALLM.Providers.Support.ImageMime.accept_mimes/1for:openaiand the shared 20 MB ceiling — which is exactly OpenAI's documented moderation image limit — and then for whether its bytes can actually be produced. Every failure shape converts to:invalid_requestrather than widening this callback's error union to includeALLM.Error.ValidationError—moderate/2returns%ALLM.Error.ModerationAdapterError{}and nothing else. Each carries the offending item's position onmetadata.index:Failure metadataaddsMIME outside OpenAI's accept set %{image_error: :unsupported_image_format, mime_type: mime}image over 20 MB %{image_error: :image_too_large, byte_size: bytes}%ALLM.Image{}with no:mime_type%{image_error: :missing_mime_type}image bytes unresolvable (missing file, bad base64) %{image_error: :unresolvable_image, cause: reason}item is neither a string nor an ALLM.ImagePart, in a multimodal request%{image_error: :untranslatable_item}The last two rows exist because the translator is total only over resolvable images and known item types: without them a missing file raises
MatchErrorand an off-shape item raisesFunctionClauseErrorfrom insidemoderate/2, violatingALLM.ModerationAdapterinvariant 2. An off-shape item in an all-strings:inputis still forwarded to the provider untouched — it reaches the wire there, and OpenAI answers the 400.The gate is fail-fast: the first failing item is reported, not all of them. A URL-sourced image is not fetched, so its bytes are never weighed here — OpenAI does that itself and answers a 400.
Capability pre-flight against a model catalog is NOT performed here — it lives
in ALLM.moderate/3. A direct adapter call bypasses it by design.
Result cardinality and :index
The wire's results array carries no index field, so :index is
assigned from array position — which makes ALLM.ModerationAdapter
invariant 4 this adapter's responsibility rather than the provider's. For an
all-strings :input that yields length(input) results with indices
0..n-1, matching ALLM.ModerationRequest's cardinality rule.
An :input carrying any ALLM.ImagePart is a single multimodal item —
the text and the images are judged together — so the provider answers with
exactly one results entry and :index is 0, however many elements the
list had. This is a property of OpenAI's wire, not an ALLM choice: the
documented multimodal example posts a text block plus an image block and gets
one verdict back. ALLM.ModerationRequest.multimodal?/1 makes the cardinality
derivable before the call. Recorded at
test/fixtures/openai/moderations/recorded/multimodal_text_image.json.
ALLM.ImagePart.detail is dropped
OpenAI's documented moderation request shape carries no detail key inside
image_url, and no OpenAI documentation mentions detail control on this
endpoint, so the translator omits it and fires a single deferred-form
Logger.debug/1 per process when a part carries one.
The disposition is inferred from the documented request shape, not
confirmed on the wire, and it cannot be confirmed here: this endpoint
ignores unknown fields (see above), so a detail-bearing request coming back
200 is evidence of nothing. Only a response-observable difference could
promote the row, and the recorder's paired arm found none.
Decoding the category maps
Three fields, three deliberately different treatments:
categorieskeeps only boolean values. OpenAI's reference typesillicitandillicit/violentas"boolean or null", andALLM.ModerationResult.categoriesis typed%{String.t() => boolean()}, so a null-valued key is dropped rather than propagated asnilinto a typed map.ALLM.ModerationResult.flagged_categories/1andALLM.ModerationResult.score/2already handle an absent key.category_scorescoerces numbers with* 1.0, because a JSON0decodes to an integer and would break the declaredfloat()type.category_applied_input_typespasses through unchanged under the prefix-dropped name:applied_input_types, defaulting to%{}when the provider omits it.
A results entry whose flagged is missing or non-boolean is
:malformed_response. The verdict is never coerced or defaulted here: a
moderation result that silently reads "clean" because the payload was
truncated is the one failure mode worth an error.
Request-id preservation
opts[:request_id] is reflected onto response.request_id unchanged. When it
is absent, the adapter falls back to OpenAI's x-request-id response header.
request.metadata round-trips onto response.metadata untouched.
The x-request-id fallback is unreachable through ALLM.moderate/3
The façade always supplies opts[:request_id] (it generates one when the
caller does not), so on that path the left branch always wins and OpenAI's
own correlation id is never observed. It surfaces only on a direct
moderate/2 / decode_response/4 call that omits opts[:request_id].
Identical to ALLM.Providers.OpenAI.Embeddings, deliberately.
Error-struct hygiene
%ALLM.Error.ModerationAdapterError{} derives Jason.Encoder and is commonly
logged and persisted, so this adapter never copies a raw response body, a
request header, or any Authorization value into :cause, :metadata, or
:message. There is no body_preview. Provider error messages pass through
a redactor that replaces key-shaped tokens with [REDACTED].
The live 2026-08-31 probe found OpenAI masking the key in its real
moderation 401 text (sk-proj-*****…9900), so no provider-authored message
from this endpoint is currently known to carry key material. The redactor is
defence in depth, and its test target is the deliberately-planted token in
test/fixtures/openai/moderations/synthesized/error_401.json.
Retry integration
HTTP-error closures return {:retry, delay_ms, error} for 429 (honouring
Retry-After), 5xx, timeouts, and transport failures; ALLM.Retry.run/3 is
wrapped around each attempt. The closure returns real reason atoms rather
than swapping in HTTP status codes, so for :rate_limited,
:provider_unavailable, and :network_error the façade's widened retry_on
list is what decides — none of them appears in this adapter's own
opts[:retry] policy, which defaults to :default and whose retry_on is
[429, 500, 502, 503, 504, :timeout]. A 500 is therefore not retried by
the adapter's own loop; that is pinned by
test/allm/providers/openai/moderation_wire_test.exs:373-381.
:timeout is the documented exception: it is a member of both lists, so
through ALLM.moderate/3 the adapter's inner ALLM.Retry.run/3 and the
façade's outer one both retry it and the attempt budgets multiply
(3 × 3 = 9 HTTP attempts at the default policy, against 3 for every other
retryable reason). A direct moderate/2 call takes the inner loop only and
makes 3 for :timeout; a reason the adapter's own policy does not retry
costs 1. ALLM.Providers.OpenAI.Embeddings has the byte-identical shape
through ALLM.embed/3, so this is a pre-existing library-wide characteristic
rather than a moderation one; it is tracked in ASKS.md.
Test-injection escape hatch
moderate/2 honours opts[:adapter_opts][:moderation_script] as a documented
test-only short-circuit: when the key is present, the call delegates to
ALLM.Providers.FakeModeration.moderate/2 BEFORE any pre-flight gate runs and
returns its result verbatim. This is what lets the injectable
ALLM.ModerationAdapter conformance suite drive a real adapter without an
HTTP stub library — and it is why conformance cases 3 and 4 pass no script.
The switch keys on the presence of that per-call key and nothing else — no
environment variable, no application config, no :persistent_term — so it
stays confined to an explicit argument. Production callers do not populate it.
prepare_request/2 deliberately does NOT delegate under the same key: a
scripted response has no Req.Request analogue, so it returns a stub error
instead.
Summary
Functions
Return the maximum number of inputs this adapter will send in one
/v1/moderations call.
Classify a moderation request synchronously against OpenAI.
Return an unfired Req.Request configured exactly as moderate/2 would fire
it, for callers who need to add headers, middleware, or their own retry
wrapper before dispatch.
Functions
@spec max_batch_size() :: pos_integer()
Return the maximum number of inputs this adapter will send in one
/v1/moderations call.
Per-module and constant, not per-model. OpenAI documents no cap; this number is the top rung of the recorder's ladder arm, every rung of which was accepted on 2026-08-31 — a demonstrated floor rather than a discovered maximum.
Examples
iex> ALLM.Providers.OpenAI.Moderation.max_batch_size
1000
@spec moderate( ALLM.ModerationRequest.t(), keyword() ) :: {:ok, ALLM.ModerationResponse.t()} | {:error, ALLM.Error.ModerationAdapterError.t()}
Classify a moderation request synchronously against OpenAI.
Returns {:ok, %ALLM.ModerationResponse{}} or
{:error, %ALLM.Error.ModerationAdapterError{}}; every HTTP-shaped failure
converts, including transport errors. The one documented exception is
ALLM.Keys.fetch!/2, which raises
%ALLM.Error.EngineError{reason: :missing_key} by design and is not rescued
here — both pre-flight gates run ahead of it, so a request rejected pre-flight
never needs a key.
See the module documentation for the gate order, the wire-field map, the
no-injected-defaults policy for a nil :model, and the
adapter_opts[:moderation_script] test-injection short-circuit.
Examples
iex> req = ALLM.ModerationRequest.new(input: ["is this ok?"])
iex> opts = [adapter_opts: [moderation_script: [{:flagged, ["violence"]}]]]
iex> {:ok, resp} = ALLM.Providers.OpenAI.Moderation.moderate(req, opts)
iex> ALLM.ModerationResponse.flagged_categories(resp)
["violence"]
iex> req = ALLM.ModerationRequest.new(input: [])
iex> {:error, err} = ALLM.Providers.OpenAI.Moderation.moderate(req, [])
iex> err.reason
:invalid_request
@spec prepare_request( ALLM.ModerationRequest.t(), keyword() ) :: {:ok, Req.Request.t()} | {:error, ALLM.Error.ModerationAdapterError.t()}
Return an unfired Req.Request configured exactly as moderate/2 would fire
it, for callers who need to add headers, middleware, or their own retry
wrapper before dispatch.
The pre-flight gates run first, so this is defined only for a request whose
input is non-empty and whose item count is no greater than max_batch_size/0
(ALLM.ModerationAdapter invariant 10).
Under opts[:adapter_opts][:moderation_script] this returns a stub error
rather than delegating to ALLM.Providers.FakeModeration — a scripted
response has no Req.Request analogue. That asymmetry with moderate/2 is
deliberate.
Examples
iex> req = ALLM.ModerationRequest.new(input: ["hi"], model: "omni-moderation-latest")
iex> {:ok, http} = ALLM.Providers.OpenAI.Moderation.prepare_request(req, api_key: "sk-x")
iex> URI.to_string(http.url)
"https://api.openai.com/v1/moderations"