ALLM.Validate (allm v0.6.1)

Copy Markdown View Source

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

classification_request(req)

@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}]

embedding_request(req)

@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

image_request(req)

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

  • :generate requires non-empty :prompt AND :input_images == [].
  • :edit requires non-empty :prompt AND length(: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

message(msg)

@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

moderation_request(req)

@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

request(req)

@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

session(s)

@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

speech_request(req, opts \\ [])

@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 :input rules are skipped, and whatever :input holds 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

thread(t)

@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

tool(t)

@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

transcription_request(req)

@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}]

transcription_stream_request(req)

@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}]