ALLM.Providers.Fake (allm v0.4.3)

Copy Markdown View Source

Deterministic, scripted adapter for testing. Implements both ALLM.Adapter and ALLM.StreamAdapter.

Layer B — runtime. Fake is the canonical testing adapter that every orchestration phase (5, 6, 7, 8) tests against. It carries serializable plain data in adapter_opts and passes both the adapter- and stream-adapter conformance harnesses.

What Fake is (and isn't)

Fake ignores the %ALLM.Request{} passed to generate/2 and stream/2. It never inspects the request's :messages, :tools, :tool_choice, :temperature, :max_tokens, or any other field. The scripted response is produced irrespective of the request. This is intentional: Fake is for testing orchestration, not provider-wire fidelity. Testing tool orchestration happens at (ToolRunner) and (Chat) — Fake's scripts directly emit {:tool_call, _} entries to simulate what the model would return; the caller sets up an engine with tools: [...] and a {:tool_call,...} script, and the orchestrator dispatches to the tool executor exactly as it would with a real provider.

Script shapes

Fake accepts two disjoint script shapes on adapter_opts. See ALLM.Providers.Fake.Script for the full tag-to-shape table.

Spec (user-facing)

adapter_opts: [
  script: [
    {:text, "Hello "},
    {:text, "world"},
    {:finish, :stop}
  ]
]

Entry tags: :text, :tool_call, :tool_call_delta, :usage, :raw_chunk, :finish, :error (2-tuple), :delay, :sleep (deprecated alias of :delay).

harness

adapter_opts: [
  script: [{:ok, %{output_text: "hi"}}],
  stream_script: [[{:text_delta, "hel"}, {:text_delta, "lo"}, {:finish, :stop}]]
]

Entry tags (non-streaming, on :script): {:ok, map}, {:error, reason_atom, keyword}. Entry tags (streaming, on :stream_script): :text_delta, :finish, :preflight_error, :error_event, :stream_error, shared-semantics :tool_call / :finish.

Per-entry-point key precedence

Entry pointKey precedence
generate/2:scripts > :script (wrapped as [script]). :stream_script is not consulted; if only :stream_script is set, generate/2 returns :no_scripted_response.
stream/2:stream_script > :scripts > :script (wrapped as [script]).

Multi-call scripting uses :scripts / :stream_script as a list of lists each call consumes one inner list and advances the cursor.

Disambiguation

For generate/2, both shapes share the :script key and are disambiguated by leading entry tag. The :error tag disambiguates by tuple_size/1 (2 →, 3 → harness). For stream/2, distinct keys disambiguate.

Cursor behaviour

Multi-call scripts (:scripts / :stream_script) advance a per-process cursor on every call. The cursor lives in the process dictionary at {:allm_fake_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 (§31):

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

At the façade the cursor keys on engine identity, so the historical content-hash footgun is gone. Two engines built with content-equal :scripts / :stream_script values and driven through ALLM.generate/3, ALLM.stream_generate/3, ALLM.chat/3, ALLM.stream/3, ALLM.step/3, or the ALLM.Session quartet get distinct engine.id cursor keys — each engine's first call reads index 0, even in the same process.

The footgun remains only for DIRECT adapter calls. ALLM.Providers.Fake.generate(req, opts) / .stream(req, opts) invoked without an engine receive no :cursor_key and fall back to :erlang.phash2(scripts) — two direct calls with content-equal scripts in the same process still share a cursor. Workaround for that path: pass distinct adapter_opts[:script_cursor] Agent pids from start_script_cursor/0.

The explicit-Agent override also supports cross-process cursor sharing (Task.async/1 over the adapter call) and mitigates the rare hash-collision case (:erlang.phash2/1 is a 27-bit hash).

Because :cursor_key rides in adapter_opts, it surfaces in the :record side-channel: a façade-driven call recorded via adapter_opts[:record] now includes {:cursor_key, engine.id} in the forwarded opts. Focused assertions (model/temperature/api_key) are unaffected; an assertion on the exact recorded adapter_opts keyword list must account for it.

Testing patterns

Façade-driven tests need no cursor ceremony. Engines built via ALLM.Engine.new/1 (or any provider engine/1 helper) carry a unique :id, so multiple content-equal engines driven through the public façade in one process do not collide — no start_script_cursor/0 required.

Use start_script_cursor/0 for the cases identity doesn't cover: direct adapter calls (no engine, hence no :cursor_key), cross-process cursor sharing (Task.async/1 over the adapter call), or the rare phash2 collision. Reach for the explicit Agent cursor whenever a test bypasses the façade:

cursor = ALLM.Providers.Fake.start_script_cursor
opts = [adapter_opts: [scripts: [...]] ++ [script_cursor: cursor]]

Worked examples: test/allm/providers/fake_test.exs:143 and test/allm/providers/fake/fixtures_test.exs:66.

Cleanup observation

When adapter_opts[:cleanup_observer] is a :counters ref (as created by :counters.new(1, [:atomics])), the Stream.resource/3 after_fun increments index 1 on every normal termination path — consumer Enum.take/N, Stream.take_while/2 returning false, Stream.run/1 scope exit, throws from the reducer, consumer process exit with a trappable reason. The counter increments at most once per stream (not once-per-event).

Halt-safety test shape:

ref = :counters.new(1, [:atomics])
{:ok, stream} = ALLM.Providers.Fake.stream(req,
  adapter_opts: [script: [...], cleanup_observer: ref])
_ = Enum.take(stream, 2)
# :counters.get(ref, 1) == 1 within 500 ms

Brutal-kill caveat. Stream.resource/3's after_fun does NOT run when the consumer is killed with Process.exit(pid, :kill) — brutal exits skip all cleanup by OTP design. Real provider adapters address this via Finch's own monitor-based connection cleanup; Fake has no HTTP ref to leak so the caveat is purely documentary. Tests assert cleanup on normal halts only; no test should simulate :kill.

Adapter event vocabulary (streaming)

Emitted : :message_started, :text_delta, :text_completed, :tool_call_started, :tool_call_delta, :tool_call_completed, :message_completed, :raw_chunk, :error.

Not emitted (orchestrator-owned — /7): :tool_execution_started, :tool_execution_completed, :tool_result_encoded, :ask_user_requested, :tool_halt, :step_completed, :chat_completed.

Backpressure and delays

{:delay, ms} entries call Process.sleep/1 inside the next_fun of the Stream.resource/3 — the delay blocks the consumer's reducing process, not a simulated provider. {:delay, _} is front-loaded: the interpreter sleeps before consuming the NEXT entry, so placing {:delay, ms} as the FIRST entry delays :message_started. Timing tests measure the wall-clock interval between the emit preceding the {:delay, _} entry and the emit following it.

{:sleep, ms} is a deprecated alias for {:delay, ms} — one Logger.warning/1 fires per BEAM lifetime when {:sleep, _} is used. Deletion target: v0.3.

Examples

iex> req = ALLM.Request.new([%ALLM.Message{role: :user, content: "hi"}])
iex> opts = [adapter_opts: [script: [{:text, "hi"}, {:finish, :stop}]]]
iex> {:ok, resp} = ALLM.Providers.Fake.generate(req, opts)
iex> {resp.output_text, resp.finish_reason}
{"hi", :stop}

Summary

Functions

Read the current cursor index for an Agent-backed cursor. Used in tests to assert how many calls have been consumed.

Execute a scripted non-streaming request.

Return the canonical %AdapterError{} for a script-exhausted call.

Start an Agent-backed script cursor for cross-process multi-call scripting and for disambiguating content-equal scripts in the same process (see moduledoc "Cursor behaviour").

Open a scripted streaming request.

Functions

cursor_index(pid)

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

Read the current cursor index for an Agent-backed cursor. Used in tests to assert how many calls have been consumed.

generate(request, opts)

@spec generate(
  ALLM.Request.t(),
  keyword()
) :: {:ok, ALLM.Response.t()} | {:error, ALLM.Error.AdapterError.t()}

Execute a scripted non-streaming request.

Reads the script from opts[:adapter_opts], validates via ALLM.Providers.Fake.Script.validate!/1, resolves the cursor, folds the current call's entries into a %ALLM.Response{} via ALLM.Providers.Fake.Script.fold_to_response/1, and advances the cursor.

Ignores the %Request{} entirely — the scripted response is produced irrespective of the request's messages, tools, or params (see "What Fake is (and isn't)" in the moduledoc).

Returns {:ok, %ALLM.Response{}} on a scripted success (including harness-shape {:ok, _} entries), {:error, %AdapterError{}} on a scripted failure or a script-exhausted cursor. adapter_opts[:request_id] is propagated onto response.request_id verbatim.

Examples

iex> req = ALLM.Request.new([%ALLM.Message{role: :user, content: "x"}])
iex> opts = [adapter_opts: [script: [{:text, "hello"}, {:finish, :stop}]]]
iex> {:ok, resp} = ALLM.Providers.Fake.generate(req, opts)
iex> resp.output_text
"hello"

script_exhausted_error()

@spec script_exhausted_error() :: ALLM.Error.AdapterError.t()

Return the canonical %AdapterError{} for a script-exhausted call.

Spec phrases this as {:error, :no_scripted_response}; the atom is preserved as the struct's :reason field via the enum amendment.

Examples

iex> err = ALLM.Providers.Fake.script_exhausted_error
iex> err.reason
:no_scripted_response
iex> err.message
"no scripted response"

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 (see moduledoc "Cursor behaviour").

Pass the returned pid as adapter_opts[:script_cursor]; subsequent calls increment the cursor on the Agent rather than on the process dictionary.

Examples

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

stream(request, opts)

@spec stream(
  ALLM.Request.t(),
  keyword()
) :: {:ok, Enumerable.t()} | {:error, ALLM.Error.AdapterError.t()}

Open a scripted streaming request.

Reads the script from opts[:adapter_opts], validates via ALLM.Providers.Fake.Script.validate!/1, resolves the cursor, and returns a lazy Enumerable.t of ALLM.Event values. No event fires until the consumer reduces.

Key precedence: :stream_script > :scripts > :script (wrapped as [script]). Empty opts return {:error, script_exhausted_error} (no stream opened).

When the first entry of the current call is a harness-shape {:preflight_error, reason, opts}, returns synchronously as {:error, %AdapterError{reason: ^reason,...opts}} — no stream is opened.

The returned stream emits :message_started on open, per-entry events via ALLM.Providers.Fake.Script.interpret/1, and closes with :message_completed (prepended by :text_completed if any :text/:text_delta was emitted). {:delay, ms} / {:sleep, ms} entries call Process.sleep/1 and yield no events. after_fun increments adapter_opts[:cleanup_observer] (a :counters ref, when present).

Ignores the %Request{} (see "What Fake is (and isn't)" in the moduledoc).

Examples

iex> req = ALLM.Request.new([%ALLM.Message{role: :user, content: "x"}])
iex> opts = [adapter_opts: [script: [{:text, "hi"}, {:finish, :stop}]]]
iex> {:ok, stream} = ALLM.Providers.Fake.stream(req, opts)
iex> events = Enum.to_list(stream)
iex> Enum.any?(events, &match?({:text_delta, %{delta: "hi"}}, &1))
true