ALLM.Providers.OpenAI.Moderation (allm v0.6.1)

Copy Markdown View Source

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.

ConcernOpenAI
EndpointPOST https://api.openai.com/v1/moderations (not overridable)
Authauthorization: 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 sourcea {:url, _} ALLM.Image forwards its URL verbatim; every other source is inlined as a data: URI
detailnever sent. ALLM.ImagePart.detail is dropped with a one-per-process Logger.debug/1 — see below
Modelmodel — omitted when nil; OpenAI's own server default resolved to omni-moderation-latest
OptionsALLM.ModerationRequest.options is merged onto the body under the structural fields
Verdictresults[].flagged
Categoriesresults[].categories — 13 slash-named keys, provider-shaped and string-keyed
Scoresresults[].category_scores — 13 float keys
Applied typesresults[].category_applied_input_types → :applied_input_types (prefix dropped)
Indexabsent from the wire — assigned from array position (invariant 4)
Usagenone. The endpoint is free and returns no usage object
Response idtop-level id ("modr-…"), one per HTTP call
Correlationx-request-id response header
Error envelope{"error": {"message", "type", "param", "code"}}
Batch capsee 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:

    Failuremetadata 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

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

max_batch_size()

@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(request, opts)

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(request, opts)

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