# `ALLM.Providers.OpenAI.Moderation`
[🔗](https://github.com/cykod/ALLM/blob/v0.6.1/lib/allm/providers/openai/moderation.ex#L1)

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.options` key 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:

  1. **Empty input.** `input: []` → `:invalid_request`
     (`ALLM.ModerationAdapter` invariant 6).
  2. **Batch size.** item count `> max_batch_size/0` → `:batch_too_large`
     with `metadata: %{count: n, max: max}` (invariant 5). The gate measures
     the **item** count invariant 3 defines, not the raw list length: an
     `:input` carrying any `ALLM.ImagePart` is exactly **one** multimodal
     item. An `:input` that is not a list at all is rejected here as
     `:invalid_request` with `metadata: %{field: :input}` rather than raising.
  3. **Images.** Every `ALLM.ImagePart` in `:input` is validated by
     `ALLM.Providers.Support.ImageMime.validate/2` against
     `ALLM.Providers.Support.ImageMime.accept_mimes/1` for `:openai` and 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_request` rather than widening
     this callback's error union to include `ALLM.Error.ValidationError` —
     `moderate/2` returns `%ALLM.Error.ModerationAdapterError{}` and nothing
     else. Each carries the offending item's position on `metadata.index`:

     | Failure | `metadata` adds |
     |---|---|
     | MIME 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 `MatchError` and an off-shape item raises `FunctionClauseError`
     from inside `moderate/2`, violating `ALLM.ModerationAdapter`
     invariant 2. An off-shape item in an **all-strings** `:input` is 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:

  * `categories` keeps only **boolean** values. OpenAI's reference types
    `illicit` and `illicit/violent` as `"boolean or null"`, and
    `ALLM.ModerationResult.categories` is typed `%{String.t() => boolean()}`,
    so a null-valued key is **dropped** rather than propagated as `nil` into
    a typed map. `ALLM.ModerationResult.flagged_categories/1` and
    `ALLM.ModerationResult.score/2` already handle an absent key.
  * `category_scores` **coerces** numbers with `* 1.0`, because a JSON `0`
    decodes to an integer and would break the declared `float()` type.
  * `category_applied_input_types` passes 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` {: .warning}
>
> 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.

# `max_batch_size`

```elixir
@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

# `moderate`

```elixir
@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

# `prepare_request`

```elixir
@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"

---

*Consult [api-reference.md](api-reference.md) for complete listing*
