ALLM.TranscriptionAdapter behaviour (allm v0.6.1)

Copy Markdown View Source

Speech-to-text provider adapter contract.

Layer B — runtime. Implementations take an ALLM.TranscriptionRequest plus a keyword opts list and return either {:ok, %ALLM.TranscriptionResponse{}} or {:error, %ALLM.Error.TranscriptionAdapterError{}}. An engine carries its transcription adapter in the :transcription_adapter slot, independently of the chat :adapter and of :speech_adapter.

Minimum impl skeleton

defmodule MyTranscriptionProvider do
  @behaviour ALLM.TranscriptionAdapter

  alias ALLM.Error.TranscriptionAdapterError

  @impl true
  def max_audio_bytes, do: 25 * 1024 * 1024

  @impl true
  def transcribe(%ALLM.TranscriptionRequest{audio: audio} = request, opts) do
    # Invariants 3 and 4, in this order, before any I/O and before
    # ALLM.Keys.fetch!/2.
    max = max_audio_bytes()

    case ALLM.Audio.size(audio) do
      {:error, cause} ->
        {:error,
         TranscriptionAdapterError.new(:invalid_request, metadata: %{cause: cause})}

      {:ok, count} when count > max ->
        {:error,
         TranscriptionAdapterError.new(:invalid_request,
           metadata: %{count: count, max: max}
         )}

      {:ok, _count} ->
        # Resolve the key, upload the audio via Req, decode the text.
        {:ok,
         %ALLM.TranscriptionResponse{
           text: "...",
           request_id: Keyword.get(opts, :request_id),
           metadata: request.metadata
         }}
    end
  end
end

Gate order

Every transcription adapter runs its gates in one fixed order, all before ALLM.Keys.fetch!/2: resolvable (invariant 3) → size (invariant 4) → MIME (adapter-specific) → key resolution. MIME acceptance is not a behaviour invariant, because the accepted set differs per provider; each adapter gates its own and documents it.

ALLM.Audio.size/1 is the byte resolver for the first two gates. It stats a {:file, path} source without reading it.

Invariants

  1. transcribe/2 returns exactly {:ok, %ALLM.TranscriptionResponse{}} or {:error, %ALLM.Error.TranscriptionAdapterError{}} — never a bare struct, never a three-tuple. The one documented exception is ALLM.Keys.fetch!/2, which raises %ALLM.Error.EngineError{reason: :missing_key} by design. The transcription façade enforces this by raising ArgumentError on any other shape, so the conformance suite cannot observe it.
  2. On success, response.text is a binary (possibly "" for silence), and response.usage is an %ALLM.Usage{}, never nil.
  3. Audio whose bytes cannot be resolved (a missing file, invalid base64, an off-shape source) is rejected with :invalid_request and a metadata.cause naming why, before any I/O and before ALLM.Keys.fetch!/2.
  4. Audio larger than max_audio_bytes/0 is rejected with :invalid_request, metadata.count (the byte count) and metadata.max, under the same ordering.
  5. max_audio_bytes/0 returns a pos_integer().
  6. opts[:request_id] is reflected onto response.request_id when supplied.
  7. request.metadata round-trips onto response.metadata unchanged.
  8. opts[:request_timeout] is honoured; expiry produces {:error, %ALLM.Error.TranscriptionAdapterError{reason: :timeout}}. When absent, the adapter applies its own default and documents it in its transcribe/2 @doc.
  9. prepare_request/2 (optional) returns an unfired Req.Request configured exactly as transcribe/2 would fire it, and is defined only for audio that passes invariants 3 and 4.
  10. Span flags. request.timestamps and request.logprobs count only when exactly true. A set flag the adapter cannot honour is refused with :unsupported_feature and metadata.field (the flag), :timestamps checked first, before any I/O and before ALLM.Keys.fetch!/2. On success, response.spans is nil iff neither flag is true; otherwise it is a list of ALLM.TranscriptSpan ([] when nothing was spoken). On every span, start_seconds and end_seconds are non-nil only if timestamps: true, and logprob only if logprobs: true: an attribute the provider sent but the caller did not ask for is dropped. With both flags off the adapter ignores the provider's span data. The one exception is a scripted %ALLM.TranscriptionResponse{} that ALLM.Providers.FakeTranscription returns verbatim.

Cleanup invariant: none. Req.request/1 owns its connection lifecycle.

Summary

Callbacks

Return the largest audio payload, in bytes, the adapter accepts in one request.

Escape hatch: return a configured but unfired Req.Request that the caller can further customize before firing.

Transcribe an audio clip.

Callbacks

max_audio_bytes()

@callback max_audio_bytes() :: pos_integer()

Return the largest audio payload, in bytes, the adapter accepts in one request.

Per-module, one number for the adapter. It is the cap the size gate (invariant 4) measures against, and what a caller checks a file against before uploading it.

prepare_request(t, keyword)

(optional)
@callback prepare_request(
  ALLM.TranscriptionRequest.t(),
  keyword()
) :: {:ok, Req.Request.t()} | {:error, ALLM.Error.TranscriptionAdapterError.t()}

Escape hatch: return a configured but unfired Req.Request that the caller can further customize before firing.

Optional. Per invariant 9 it is defined only for audio that passes the resolvable and size gates.

transcribe(t, keyword)

Transcribe an audio clip.

Returns {:ok, %ALLM.TranscriptionResponse{}} on success, or {:error, %ALLM.Error.TranscriptionAdapterError{}} on every failure shape. See ALLM.Error.TranscriptionAdapterError for the closed reason enum.