Pure validators for Layer A input shapes.
Every validator returns :ok or {:error, %ALLM.Error.ValidationError{}}
with a machine-readable :errors list of {field, reason} tuples. The
field is either a single atom (top-level) or a path of atoms/indices
(e.g. [:messages, 2, :role]) when the failure is nested.
Multimodal content parts use the %ALLM.TextPart{} and %ALLM.ImagePart{}
Layer-A structs. A content list whose elements are not one of those two
structs is rejected with {:content, :invalid_part_type} — raw maps in
content lists are not accepted.
Field-error vocabulary — invalid_part_type extension
The field-error tuple {:content, :invalid_part_type} retains its
atom-only second element so existing pattern-matching callers continue to
match. The structured detail rides on the surrounding
%ValidationError{}'s :metadata map:
%ALLM.Error.ValidationError{
errors: [{:content, :invalid_part_type}],
metadata: %{
invalid_part_type: %{
expected: [ALLM.TextPart, ALLM.ImagePart],
got: <module>
}
},
message: "validation failed: content element is not %ALLM.TextPart{} or %ALLM.ImagePart{} (got: <module>)"
}<module> is the offending element's struct module, or Map when the
element is a plain map. The structured :metadata makes the failure
machine-readable; Exception.message/1 makes it human-readable.
Validators are opt-in: constructors like ALLM.Request.new/2 do not call
these functions. Users invoke request/1, message/1, tool/1,
thread/1, session/1, image_request/1, embedding_request/1,
moderation_request/1, speech_request/1, transcription_request/1,
transcription_stream_request/1, or classification_request/1 explicitly
when they need a check before dispatch.
Summary
Functions
Validate an %ALLM.ClassificationRequest{}.
Validate an %ALLM.EmbeddingRequest{}.
Validate an %ALLM.ImageRequest{}.
Validate an %ALLM.Message{}.
Validate an %ALLM.ModerationRequest{}.
Validate an %ALLM.Request{}.
Validate an %ALLM.Session{}.
Validate an %ALLM.SpeechRequest{}.
Validate an %ALLM.Thread{}.
Validate an %ALLM.Tool{}.
Validate an %ALLM.TranscriptionRequest{}.
Validate an %ALLM.TranscriptionStreamRequest{}.
Functions
@spec classification_request(ALLM.ClassificationRequest.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.ClassificationRequest{}.
Returns :ok when every rule passes, or
{:error, %ALLM.Error.ValidationError{reason: :invalid_classification_request, errors: [...]}}.
All rules accumulate into one error list except {:questions, :invalid_shape},
which hard-rejects: every per-question rule presupposes a map.
Request rules: :questions a non-empty map; :state a non-empty string,
map or list (nil is :empty; a struct or a keyword list is
:invalid_shape, since a keyword list is almost always misplaced opts)
that can be JSON-encoded (:not_json_encodable); :model nil or a
binary.
Per-question rules, with paths [:questions, id | ...]: the id is a
non-empty binary (:invalid_id); the value is an
%ALLM.ClassificationQuestion{} (:invalid_question); :type is
:choice, :score or :yes_no (:invalid_type); :instructions is a
non-empty, JSON-encodable string, map or list — as for :state, a struct
or a non-empty keyword list is :invalid_shape. :criteria rules run only
for a known type, because the shape depends on it: a choice needs a
non-empty map whose keys are non-empty binaries (:invalid_option per
bad key); a score needs a list of at least two levels (:too_few_levels
for one — a single level always scores zero); a yes/no needs nil or a
map with keys among "true" and "false".
Provider limits — how many options or levels a provider accepts, or whether each element of a list state is text — are deliberately NOT checked here. They belong to the adapter that knows them. Checking encodability never raises, even for a map with a tuple key.
Examples
iex> q = ALLM.ClassificationQuestion.yes_no("Is a refund requested?")
iex> req = ALLM.ClassificationRequest.new(state: "I want my money back", questions: %{"refund" => q})
iex> ALLM.Validate.classification_request(req)
:ok
iex> q = ALLM.ClassificationQuestion.score("How angry?", ["Calm"])
iex> req = ALLM.ClassificationRequest.new(state: nil, questions: %{"anger" => q})
iex> {:error, err} = ALLM.Validate.classification_request(req)
iex> err.reason
:invalid_classification_request
iex> err.errors
[{:state, :empty}, {[:questions, "anger", :criteria], :too_few_levels}]
@spec embedding_request(ALLM.EmbeddingRequest.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.EmbeddingRequest{}.
Returns :ok when every rule passes, or
{:error, %ALLM.Error.ValidationError{reason: :invalid_embedding_request, errors: [...]}}.
All rules accumulate into one error list except {:input, :invalid_shape},
which hard-rejects: every [:input, idx] rule presupposes a list, so
evaluating them against a non-list would be meaningless.
Field rules: :input a list of non-empty binaries (an empty list is
rejected — a provider would 400 on it); :dimensions nil or a positive
integer; :task_type nil or a member of ALLM.EmbeddingRequest.task_type/0;
:truncate a boolean; :model nil or a binary.
Per-model dimension caps are deliberately NOT checked here — that is
ALLM.Capability.preflight_embedding/2's job, which has the model
metadata this validator lacks.
Examples
iex> ALLM.Validate.embedding_request(ALLM.EmbeddingRequest.new(input: ["a chunk"]))
:ok
iex> req = ALLM.EmbeddingRequest.new(input: [])
iex> {:error, err} = ALLM.Validate.embedding_request(req)
iex> err.reason
:invalid_embedding_request
iex> {:input, :empty} in err.errors
true
@spec image_request(ALLM.ImageRequest.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.ImageRequest{}.
Returns :ok when every rule passes, or
{:error, %ALLM.Error.ValidationError{reason: :invalid_image_request, errors: [...]}}
accumulating ALL failed rules — no hard-reject (matches request/1's
accumulator pattern).
Operation-arity rules:
:generaterequires non-empty:promptAND:input_images == [].:editrequires non-empty:promptANDlength(:input_images) in 1..2.
Any other :operation (including :variation, removed in v0.6.0) fails
with {:operation, :unknown}.
Field rules: :n integer ≥ 1; :response_format in [:binary, :base64, :url];
:size in {pos_integer, pos_integer} | String.t | :auto | nil;
:input_images a list of %ALLM.Image{}; :mask %ALLM.Image{} or nil.
Examples
iex> ALLM.Validate.image_request(ALLM.ImageRequest.new(prompt: "a kestrel"))
:ok
iex> req = ALLM.ImageRequest.new(prompt: nil, operation: :generate)
iex> {:error, err} = ALLM.Validate.image_request(req)
iex> err.reason
:invalid_image_request
iex> {:prompt, :required_for_operation} in err.errors
true
@spec message(ALLM.Message.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.Message{}.
Returns :ok or {:error, %ALLM.Error.ValidationError{}}. A content list
whose elements are not %ALLM.TextPart{} or %ALLM.ImagePart{} is
rejected with {:content, :invalid_part_type} — raw maps are not
accepted in content lists.
Examples
iex> ALLM.Validate.message(%ALLM.Message{role: :user, content: "hi"})
:ok
iex> {:error, err} = ALLM.Validate.message(%ALLM.Message{role: :tool, content: "ok"})
iex> err.reason
:invalid_message
iex> {:tool_call_id, :required} in err.errors
true
@spec moderation_request(ALLM.ModerationRequest.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.ModerationRequest{}.
Returns :ok when every rule passes, or
{:error, %ALLM.Error.ValidationError{reason: :invalid_moderation_request, errors: [...]}}.
All rules accumulate into one error list except {:input, :invalid_shape},
which hard-rejects: every [:input, idx] rule presupposes a list, so
evaluating them against a non-list would be meaningless.
Field rules: :input a non-empty list whose elements are each a non-empty
binary or an %ALLM.ImagePart{} (an empty list is rejected — a provider
would 400 on it); :model nil or a binary.
Per-item image rules — MIME type and byte size — are deliberately NOT
checked here. They are provider-specific and belong to the adapter, in the
same way per-model dimension caps belong to ALLM.Capability rather than
to embedding_request/1. Batch-size caps are likewise the adapter's,
measured against its own max_batch_size().
Examples
iex> ALLM.Validate.moderation_request(ALLM.ModerationRequest.new(input: ["is this ok?"]))
:ok
iex> req = ALLM.ModerationRequest.new(input: [])
iex> {:error, err} = ALLM.Validate.moderation_request(req)
iex> err.reason
:invalid_moderation_request
iex> {:input, :empty} in err.errors
true
@spec request(ALLM.Request.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.Request{}.
Returns :ok when every field is well-formed, or
{:error, %ALLM.Error.ValidationError{reason: :invalid_request, errors: [...]}}.
Examples
iex> req = ALLM.Request.new([%ALLM.Message{role: :user, content: "hi"}])
iex> ALLM.Validate.request(req)
:ok
iex> req = ALLM.Request.new([])
iex> {:error, err} = ALLM.Validate.request(req)
iex> err.reason
:invalid_request
iex> {:messages, :empty} in err.errors
true
@spec session(ALLM.Session.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.Session{}.
Enforces status/pending_* invariants and recursively validates
the embedded thread. Thread errors carry a [:thread, :messages, idx, :field]
path prefix.
Examples
iex> ALLM.Validate.session(%ALLM.Session{})
:ok
iex> {:error, err} = ALLM.Validate.session(%ALLM.Session{status: :awaiting_user, pending_question: nil})
iex> err.reason
:invalid_session
iex> {:pending_question, :required_for_status} in err.errors
true
@spec speech_request( ALLM.SpeechRequest.t(), keyword() ) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.SpeechRequest{}.
Returns :ok when every rule passes, or
{:error, %ALLM.Error.ValidationError{reason: :invalid_speech_request, errors: [...]}}.
All rules accumulate into one error list except {:input, :invalid_shape},
which hard-rejects: the other :input rules presuppose a binary.
Field rules: :input a non-empty, valid UTF-8 binary ({:input, :empty},
{:input, :invalid_encoding}); :model, :voice and :instructions
each nil or a binary (:invalid_shape); :format nil or a member of
ALLM.SpeechRequest.formats/0 ({:format, :unknown}); :speed nil or
a number greater than zero ({:speed, :out_of_range}); :sample_rate
nil or a positive integer ({:sample_rate, :out_of_range}).
Voice names, per-provider speed ranges and per-provider sample-rate sets are deliberately NOT checked here: they differ per provider and per model, and the adapter or the provider rejects them.
Options
input: :streamed: the text arrives as a separate enumerable, not in:input. The three:inputrules are skipped, and whatever:inputholds is ignored.
Examples
iex> ALLM.Validate.speech_request(ALLM.SpeechRequest.new(input: "Hello."))
:ok
iex> {:error, err} = ALLM.Validate.speech_request(ALLM.SpeechRequest.new(input: "", format: :ogg))
iex> err.reason
:invalid_speech_request
iex> err.errors
[{:input, :empty}, {:format, :unknown}]
iex> ALLM.Validate.speech_request(ALLM.SpeechRequest.new(input: ""), input: :streamed)
:ok
@spec thread(ALLM.Thread.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.Thread{}.
Every message must pass message/1. Errors from nested messages carry a
[:messages, idx, :field] path prefix so callers can locate the offender.
Examples
iex> t = ALLM.Thread.from_messages([%ALLM.Message{role: :user, content: "hi"}])
iex> ALLM.Validate.thread(t)
:ok
iex> t = ALLM.Thread.from_messages([%ALLM.Message{role: :bogus, content: "x"}])
iex> {:error, err} = ALLM.Validate.thread(t)
iex> err.reason
:invalid_thread
iex> {[:messages, 0, :role], :unknown} in err.errors
true
@spec tool(ALLM.Tool.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.Tool{}.
Returns :ok or {:error, %ALLM.Error.ValidationError{}}. The top-level
shape of :schema is intentionally not checked — providers differ on
whether "type" => "object" is required — but non-map schemas are rejected.
:summary must be nil or a string ({:summary, :not_a_string}).
Examples
iex> tool = %ALLM.Tool{name: "weather", description: "d", schema: %{}}
iex> ALLM.Validate.tool(tool)
:ok
iex> {:error, err} = ALLM.Validate.tool(%ALLM.Tool{name: "", description: "d", schema: %{}})
iex> err.reason
:invalid_tool
iex> {:name, :empty} in err.errors
true
@spec transcription_request(ALLM.TranscriptionRequest.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.TranscriptionRequest{}.
Returns :ok when every rule passes, or
{:error, %ALLM.Error.ValidationError{reason: :invalid_transcription_request, errors: [...]}}.
All rules accumulate into one error list except {:audio, :invalid_shape},
which hard-rejects when :audio is not an %ALLM.Audio{}.
Field rules: :audio's :source one of the three ALLM.Audio.source/0
shapes with a binary payload ({[:audio, :source], :invalid_shape});
:model, :language and :prompt each nil or a binary
(:invalid_shape); :timestamps and :logprobs each a boolean
(:invalid_shape).
Whether a file exists, the audio's byte size, and its MIME type are deliberately NOT checked here. The limits differ per provider and need file I/O, so they are the adapter's.
Examples
iex> req = ALLM.TranscriptionRequest.new(audio: ALLM.Audio.from_file("clip.mp3"))
iex> ALLM.Validate.transcription_request(req)
:ok
iex> {:error, err} = ALLM.Validate.transcription_request(ALLM.TranscriptionRequest.new())
iex> err.errors
[{:audio, :invalid_shape}]
@spec transcription_stream_request(ALLM.TranscriptionStreamRequest.t()) :: :ok | {:error, ALLM.Error.ValidationError.t()}
Validate an %ALLM.TranscriptionStreamRequest{}.
Returns :ok when every rule passes, or
{:error, %ALLM.Error.ValidationError{reason: :invalid_transcription_request, errors: [...]}}.
All rules accumulate into one error list.
Field rules: :sample_rate a positive integer
({:sample_rate, :out_of_range}); :commit_strategy a member of
ALLM.TranscriptionStreamRequest.commit_strategies/0
({:commit_strategy, :unknown}); :model and :language each nil or
a binary (:invalid_shape); :timestamps and :logprobs each a
boolean (:invalid_shape); :options and :metadata each a map
(:invalid_shape).
Which sample rates a provider accepts is deliberately NOT checked here; the adapter checks it.
Examples
iex> ALLM.Validate.transcription_stream_request(ALLM.TranscriptionStreamRequest.new())
:ok
iex> cfg = ALLM.TranscriptionStreamRequest.new(sample_rate: 0, commit_strategy: :never)
iex> {:error, err} = ALLM.Validate.transcription_stream_request(cfg)
iex> err.errors
[{:sample_rate, :out_of_range}, {:commit_strategy, :unknown}]