ALLM.Providers.FakeTranscription (allm v0.6.1)

Copy Markdown View Source

Deterministic, scripted adapter for speech-to-text testing. Implements ALLM.TranscriptionAdapter and ALLM.TranscriptionStreamAdapter.

Layer B — runtime. FakeTranscription is the canonical testing transcription adapter; it ships in lib/ (not test/support/) because users need it for their own application tests, mirroring ALLM.Providers.FakeSpeech and ALLM.Providers.FakeModeration.

What FakeTranscription is (and isn't)

FakeTranscription never listens to the audio. It measures :audio to run the resolvable and size gates every ALLM.TranscriptionAdapter must run, and reads :model and :metadata to round-trip them onto the response. It has no MIME gate: accepted MIME types are a real provider's concern.

Size cap

max_audio_bytes/0 returns 1024, deliberately small so a test crosses the boundary cheaply. The gate measures against adapter_opts[:max_audio_bytes] || max_audio_bytes(). A real adapter that hands a scripted call off to this Fake passes its own cap under that key, so a scripted run with a real multi-kilobyte clip is not rejected by the Fake's small default.

Default transcript (no script)

With no script — adapter_opts[:transcription_script] absent or [] — the response carries text: "" and usage: %ALLM.Usage{}.

Spent script

A non-empty script whose cursor has run off the end does NOT fall back to the default — it returns

{:error, %ALLM.Error.TranscriptionAdapterError{
   reason: :unknown,
   metadata: %{cause: :transcription_script_exhausted}}}

Script shapes

opts[:adapter_opts][:transcription_script] accepts a list of script entries. See script/1 for the full grammar.

adapter_opts: [
  transcription_script: [
    {:ok, "The quick brown fox."},
    {:ok, %ALLM.TranscriptionResponse{...}},
    {:error, %ALLM.Error.TranscriptionAdapterError{reason: :rate_limited}},
    {:retry_until_call, 3}
  ]
]

{:retry_until_call, n} returns a synthetic %ALLM.Error.TranscriptionAdapterError{reason: :rate_limited, retry_after_ms: 0} for the first n - 1 calls against this entry, then advances to the next entry on call n. Consecutive entries of this shape chain. n must be a positive integer: any other budget raises ArgumentError naming the entry when a call reaches it, whether or not the script went through script/1.

Entrytranscribe/2stream_transcribe/3
{:ok, text}text as the transcript; with a span flag set, one :word span per wordafter the input is consumed: one partial per cumulative word prefix, one committed segment, :transcription_completed; with a span flag set, both carry the word spans
{:ok, %TranscriptionResponse{}}the struct, verbatim (span flags not applied)the struct's fields as events; a non-nil :spans rides the committed and completed events
{:error, err}{:error, err}:transcription_started, then {:error, err}
{:events, events}:unknown with metadata.cause: :stream_only_script_entryevents, verbatim
{:retry_until_call, n}:rate_limited errora stream whose only event is a :rate_limited error
absent or [] scripttext: "" (spans: [] with a span flag set):transcription_completed with text: "" (and spans: [] with a span flag set)

Span flags

With timestamps: true or logprobs: true on the request, a text entry (and the no-script default) is split on whitespace into one :word ALLM.TranscriptSpan per word: word i spans i * 0.5 to (i + 1) * 0.5 seconds with logprob -0.1. Times are kept only under timestamps: true and the logprob only under logprobs: true; a flag counts only when it is exactly true. Blank text gives spans: []; with both flags off spans is nil and no stream event carries a :spans key.

iex> req = ALLM.TranscriptionRequest.new(
...>   audio: ALLM.Audio.from_binary(<<0>>, "audio/mpeg"), logprobs: true)
iex> opts = [adapter_opts: [transcription_script: [{:ok, "hi there"}]]]
iex> {:ok, resp} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
iex> for s <- resp.spans, do: {s.text, s.start_seconds, s.logprob}
[{"hi", nil, -0.1}, {"there", nil, -0.1}]

On the stream path the one committed segment carries the same spans and :transcription_completed carries them under :spans ([] when nothing was committed). A scripted %TranscriptionResponse{} is the escape hatch: its own :spans is returned (or streamed) as scripted, whatever the flags say.

adapter_opts[:span_flags], when present, is the list of flags the caller's adapter supports. A set flag outside it is refused with {:error, %ALLM.Error.TranscriptionAdapterError{reason: :unsupported_feature, metadata: %{field: flag}}} after the audio (or sample-rate) gate and before the script is consulted, so the refusal does not advance the cursor; :timestamps is checked before :logprobs. The error's :provider is nil. Without the key both flags are supported.

iex> req = ALLM.TranscriptionRequest.new(
...>   audio: ALLM.Audio.from_binary(<<0>>, "audio/mpeg"), timestamps: true)
iex> opts = [adapter_opts: [transcription_script: [{:ok, "hi"}], span_flags: [:logprobs]]]
iex> {:error, err} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
iex> {err.reason, err.metadata.field}
{:unsupported_feature, :timestamps}

Streaming

stream_transcribe/3 resolves its script entry and advances the cursor at call time, before it returns {:ok, stream}, so two calls consume two entries even if neither stream is reduced. Streams are never retried, so on the stream path {:retry_until_call, n} returns a stream holding only the rate-limit error while the entry's visit counter (shared with transcribe/2) is below n. The input is reduced through ALLM.Providers.Support.InputPump, in another process, as a real adapter reduces it. stream_sample_rates/0 is [8_000, 16_000, 24_000].

Cursor behaviour

Multi-call scripts advance a per-process cursor on every call. The cursor lives in the process dictionary at {:allm_fake_transcription_cursor, key_id}, isolated per ExUnit test process (async: true), GC'd on pid-down, zero-setup for the common case. The key_id is chosen by this precedence:

  1. adapter_opts[:script_cursor] — an explicit Agent pid (handled separately; see start_script_cursor/0).
  2. adapter_opts[:cursor_key] — the engine's stable :id, injected by the façade dispatch chokepoint via ALLM.Engine.put_cursor_key/2.
  3. :erlang.phash2(script) — the content-hash fallback for direct adapter calls with no engine.

At the façade the cursor keys on engine identity, so two engines built with content-equal :transcription_script values each read index 0 on their first call, even in the same process. The content-hash footgun remains only for DIRECT adapter calls — ALLM.Providers.FakeTranscription.transcribe(req, opts) invoked without an engine receives no :cursor_key. Workaround for that path: pass distinct adapter_opts[:script_cursor] Agent pids from start_script_cursor/0. The {:retry_until_call, n} visit counter keys on the same identity, so content-equal engines never share a retry budget.

Test-only capture seam

Pass adapter_opts[:capture_pid] with a pid to receive a side-channel message every time transcribe/2 or stream_transcribe/3 is invoked, BEFORE any gate runs and before the script is consulted:

{ALLM.Providers.FakeTranscription, :call, %{request: request, opts: opts}}

Examples

iex> audio = ALLM.Audio.from_binary(<<0, 1, 2>>, "audio/mpeg")
iex> req = ALLM.TranscriptionRequest.new(audio: audio)
iex> opts = [adapter_opts: [transcription_script: [{:ok, "Hello."}]]]
iex> {:ok, resp} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
iex> resp.text
"Hello."

Summary

Types

One scripted transcription result.

Functions

Read the current cursor index for an Agent-backed cursor.

Return the largest audio payload FakeTranscription accepts, in bytes.

Document and validate the script grammar for adapter_opts[:transcription_script].

Start an Agent-backed script cursor for cross-process multi-call scripting and for disambiguating content-equal scripts in the same process.

Return the PCM sample rates stream_transcribe/3 accepts: [8000, 16000, 24000]. Override per call with adapter_opts[:stream_sample_rates].

Stream a scripted realtime transcription as ALLM.TranscriptionEvent values.

Execute a scripted transcription request.

Types

script_entry()

@type script_entry() ::
  {:ok, String.t()}
  | {:ok, ALLM.TranscriptionResponse.t()}
  | {:error, ALLM.Error.TranscriptionAdapterError.t()}
  | {:events, [ALLM.TranscriptionEvent.t()]}
  | {:retry_until_call, pos_integer()}

One scripted transcription result.

Functions

cursor_index(pid)

@spec cursor_index(pid()) :: non_neg_integer()

Read the current cursor index for an Agent-backed cursor.

Examples

iex> pid = ALLM.Providers.FakeTranscription.start_script_cursor()
iex> req = ALLM.TranscriptionRequest.new(audio: ALLM.Audio.from_binary("x", "audio/mpeg"))
iex> opts = [adapter_opts: [transcription_script: [{:ok, "a"}], script_cursor: pid]]
iex> {:ok, _} = ALLM.Providers.FakeTranscription.transcribe(req, opts)
iex> ALLM.Providers.FakeTranscription.cursor_index(pid)
1

max_audio_bytes()

@spec max_audio_bytes() :: pos_integer()

Return the largest audio payload FakeTranscription accepts, in bytes.

Deliberately not a provider-shaped number: 1024 bytes lets a test cross the size boundary without allocating megabytes. Override per call with adapter_opts[:max_audio_bytes].

Examples

iex> ALLM.Providers.FakeTranscription.max_audio_bytes()
1024

script(entries)

@spec script([script_entry()]) :: :ok

Document and validate the script grammar for adapter_opts[:transcription_script].

Each entry is one of:

  • {:ok, text} — return text as the transcript, with usage: %ALLM.Usage{}.
  • {:ok, %ALLM.TranscriptionResponse{}} — return the struct verbatim.
  • {:error, %ALLM.Error.TranscriptionAdapterError{}} — return the struct verbatim.
  • {:events, events} — for stream_transcribe/3, emit events verbatim. transcribe/2 answers it with :unknown and metadata.cause: :stream_only_script_entry.
  • {:retry_until_call, n} — synthetic :rate_limited for the first n - 1 calls against this entry. Consecutive entries of this shape chain into a layered budget.

Returns :ok when the script is well-formed; raises ArgumentError on the first invalid entry. Validation is opt-in: transcribe/2 does not call it, though a malformed {:retry_until_call, n} still raises when a call reaches it.

Examples

iex> ALLM.Providers.FakeTranscription.script([{:ok, "text"}, {:retry_until_call, 2}])
:ok

start_script_cursor()

@spec start_script_cursor() :: pid()

Start an Agent-backed script cursor for cross-process multi-call scripting and for disambiguating content-equal scripts in the same process.

Pass the returned pid as adapter_opts[:script_cursor].

Examples

iex> pid = ALLM.Providers.FakeTranscription.start_script_cursor()
iex> ALLM.Providers.FakeTranscription.cursor_index(pid)
0

stream_sample_rates()

@spec stream_sample_rates() :: [pos_integer()]

Return the PCM sample rates stream_transcribe/3 accepts: [8000, 16000, 24000]. Override per call with adapter_opts[:stream_sample_rates].

Examples

iex> ALLM.Providers.FakeTranscription.stream_sample_rates()
[8000, 16000, 24000]

stream_transcribe(request, input, opts)

Stream a scripted realtime transcription as ALLM.TranscriptionEvent values.

Gate, before the script is consulted: request.sample_rate must be in adapter_opts[:stream_sample_rates] || stream_sample_rates(), else a synchronous {:error, %TranscriptionAdapterError{reason: :invalid_request}} with metadata.sample_rate. Then, when adapter_opts[:span_flags] is present, the span-flag gate transcribe/2 runs (:unsupported_feature, also synchronous).

The script entry is resolved and the cursor advanced at call time, before the stream is returned. The input is reduced lazily, when the stream is, through ALLM.Providers.Support.InputPump, and every element is checked as a real adapter checks it: a binary or :commit, else metadata.cause: :invalid_input_chunk. An input whose total length is odd ends the stream with :invalid_input_chunk at end of input; an odd-length chunk followed by one that completes the sample is not an error. An input that raises ends it with :input_raised, one whose pump dies from an exit signal with :input_crashed, each with a %{kind, message} map (an atom and a string) on the error's :cause. opts[:stream_timeout] (default 60,000 ms) bounds the silence between input chunks.

Once the input is exhausted:

  • {:ok, text} — one :partial_transcript per cumulative word prefix of text, one :committed_transcript of text, then :transcription_completed. A text with no words emits no segment.
  • absent script — :transcription_completed with text: "".

:transcription_completed carries the committed text trimmed (the normative join), duration_seconds computed from the bytes consumed as bytes / (sample_rate * 2), opts[:request_id] and request.metadata.

{:error, err}, {:events, events}, {:ok, %TranscriptionResponse{}} and {:retry_until_call, n} behave as for ALLM.Providers.FakeSpeech's stream callbacks; the input is not reduced.

Examples

iex> req = ALLM.TranscriptionStreamRequest.new(sample_rate: 16_000)
iex> opts = [adapter_opts: [transcription_script: [{:ok, "hi there"}]]]
iex> {:ok, events} = ALLM.Providers.FakeTranscription.stream_transcribe(req, [<<0::size(256)-unit(8)>>], opts)
iex> for {:partial_transcript, %{text: t}} <- events, do: t
["hi", "hi there"]

transcribe(request, opts)

Execute a scripted transcription request.

Gate order, all before the script is consulted:

  1. adapter_opts[:capture_pid] side-channel (fires even for rejected calls).
  2. Resolvable — ALLM.Audio.size/1 on request.audio. A missing file, invalid base64, or a value that is not an %ALLM.Audio{} → {:error, %TranscriptionAdapterError{reason: :invalid_request}} with metadata.cause (:enoent, :eisdir, :invalid_base64, :invalid_source, …).
  3. Size — more than adapter_opts[:max_audio_bytes] || max_audio_bytes() bytes → :invalid_request with metadata.count and metadata.max.
  4. Span flags — only when adapter_opts[:span_flags] is present: a timestamps: true or logprobs: true outside that list → :unsupported_feature with metadata.field (see "Span flags" in the module docs).

Otherwise reads the script from opts[:adapter_opts][:transcription_script], advances the process-local cursor, and interprets the entry. An absent script produces the default empty transcript; a spent non-empty script returns :transcription_script_exhausted.

Propagates opts[:request_id], request.model and request.metadata onto the response and stamps provider: :fake. {:ok, %TranscriptionResponse{}} script entries are returned verbatim.

Examples

iex> req = ALLM.TranscriptionRequest.new(audio: ALLM.Audio.from_binary("abc", "audio/mpeg"))
iex> {:ok, resp} = ALLM.Providers.FakeTranscription.transcribe(req, request_id: "rid-1")
iex> {resp.text, resp.request_id, resp.usage}
{"", "rid-1", %ALLM.Usage{}}

iex> req = ALLM.TranscriptionRequest.new(audio: ALLM.Audio.from_file("/nonexistent.mp3"))
iex> {:error, err} = ALLM.Providers.FakeTranscription.transcribe(req, [])
iex> {err.reason, err.metadata.cause}
{:invalid_request, :enoent}