Current section
Files
Jump to
Current section
Files
src/erllama_model_backend.erl
%% Copyright (c) 2026 Benoit Chesneau. Licensed under the MIT License.
%% See the LICENSE file at the project root.
%%
-module(erllama_model_backend).
-moduledoc """
Behaviour describing the operations the `erllama_model` gen_statem
needs from a backing inference engine.
Two backends ship in v0.2:
`erllama_model_stub` — deterministic phash2-based stubs; used
by tests that don't have a GGUF on disk.
`erllama_model_llama` — real llama.cpp via the NIF.
Future backends (mock for fault injection, remote for distributed
inference, etc.) can plug in via this same surface.
""".
-type state() :: term().
-callback init(Config :: map()) -> {ok, state()} | {error, term()}.
-callback terminate(state()) -> ok.
-callback tokenize(state(), Text :: binary()) ->
[erllama_nif:token_id()] | {error, term()}.
-callback detokenize(state(), [erllama_nif:token_id()]) ->
binary() | {error, term()}.
-callback prefill(state(), [erllama_nif:token_id()]) -> ok | {error, term()}.
-callback decode_one(state(), ContextTokens :: [erllama_nif:token_id()]) ->
{ok, erllama_nif:token_id()}
| {eog, erllama_nif:token_id()}
| {error, term()}.
-callback kv_pack(state(), Tokens :: [erllama_nif:token_id()]) ->
binary() | {error, term()}.
-callback kv_unpack(state(), Bin :: binary()) -> ok | {error, term()}.
%% Optional seq-aware variants. Used by the multi-sequence scheduler
%% to thread per-request seq_ids through the same pack/unpack/cell-
%% removal flow the single-seq paths already use. Backends that have
%% not been ported keep the single-seq callbacks above and `seq_id =
%% 0` is implied everywhere.
-callback kv_pack(state(), Tokens :: [erllama_nif:token_id()], seq_id()) ->
binary() | {error, term()}.
-callback kv_unpack(state(), Bin :: binary(), seq_id()) -> ok | {error, term()}.
%% Optional. Free all KV cells of a given seq_id and drop any
%% per-seq tracking inside the backend. Called by the scheduler on
%% request finish (success, error, or cancel) before the seq_id is
%% returned to the idle pool.
-callback seq_rm(state(), seq_id()) -> ok | {error, term()}.
%% Optional. Drop the last KV cell of the active sequence so the
%% caller can re-prefill the corresponding token to regenerate logits
%% after a kv_unpack. Backends that don't carry a real KV cache (the
%% stub) can omit this; `erllama_model` checks `is_exported/3` and
%% skips the primer when absent.
%% Clear seq 0 of the context KV cache, resetting `n_past` to 0 so the
%% next prefill begins at position 0. Without this, a cold prefill on a
%% context that already served a previous request would auto-position
%% the new tokens after the residual KV cells from the prior call,
%% producing different output for the same prompt + seed.
-callback seq_clear(state()) -> ok | {error, term()}.
-callback seq_rm_last(state(), NTokens :: pos_integer()) ->
ok | {error, term()}.
%% Optional. Drop the last KV cell of a specific seq_id. Seq-aware
%% counterpart to seq_rm_last/2.
-callback seq_rm_last(state(), seq_id(), NTokens :: pos_integer()) ->
ok | {error, term()}.
%% Optional. Drive one batched-decode tick across a set of in-flight
%% sequences. Each op is `{prefill, [Token]}` to push the slice into
%% KV (no sampling) or `{decode, sampler_ref()}` to sample one token
%% from the prior tick's logits and decode it. Returns one result per
%% op in input order.
-callback step(state(), [{seq_id(), step_op()}]) ->
{ok, [{seq_id(), step_result()}]} | {error, term()}.
%% Optional. Build a per-request sampler chain from the same config
%% map `configure_sampler/2` accepts. Returns an opaque handle the
%% scheduler hands to `step/2` as the decode-row sampler. The chain
%% lives until `sampler_free/1` (or the backend's terminate/1).
-callback sampler_new(state(), sampler_opts()) ->
{ok, sampler_ref()} | {error, term()}.
%% Optional. Release a sampler chain previously built via
%% `sampler_new/2`. Idempotent: a double-free returns `ok` or a
%% backend-defined `{error, released}`.
-callback sampler_free(sampler_ref()) -> ok | {error, term()}.
%% Optional. Render a normalised chat request through the model's
%% chat template and tokenise in one step. The Request map carries
%% `messages`, `system`, and `tools` (any may be undefined or
%% missing). Backends that don't support chat templating can omit
%% this; callers will get `{error, not_supported}` from the public
%% API.
-callback apply_chat_template(state(), Request :: chat_request()) ->
{ok, [erllama_nif:token_id()]} | {error, term()}.
%% Optional. Compute an embedding vector for the given prompt tokens.
%% Backends that don't support embeddings can omit this.
-callback embed(state(), [erllama_nif:token_id()]) ->
{ok, [float()]} | {error, term()}.
%% Optional. Configure the per-request sampler with a GBNF grammar.
%% Equivalent to `configure_sampler(state(), #{grammar => Grammar})`.
%% Kept for backwards compatibility; new code should call
%% `configure_sampler/2`.
-callback set_grammar(state(), Grammar :: binary() | undefined) ->
{ok, state()} | {error, term()}.
%% Optional. Configure the per-request sampler from a config map.
%% Called by `erllama_model` immediately before the first decode_one
%% of an inference; the chain is reset to greedy on
%% `clear_sampler/1`. Backends that ignore sampling can omit it.
%%
%% Recognised keys (all optional): `grammar`, `repetition_penalty`,
%% `top_k`, `top_p`, `min_p`, `temperature`, `seed`. See
%% `erllama_nif:configure_sampler/2` for the precise semantics.
-callback configure_sampler(state(), sampler_opts()) ->
{ok, state()} | {error, term()}.
-callback clear_sampler(state()) -> {ok, state()} | {error, term()}.
%% Optional LoRA support. `load_adapter/2` returns an opaque handle
%% identifying the adapter; the handle is passed back to
%% `set_adapter_scale/3` and `unload_adapter/2`. `apply_adapters/2`
%% installs the current attachment set on the underlying context;
%% the model layer calls it whenever the attachment set or any scale
%% changes. The Adapters argument is a list of
%% `{Handle, Scale :: float()}` tuples; an empty list detaches
%% everything. Backends without LoRA support can omit the entire
%% group; the model layer returns `{error, not_supported}` to the
%% public API.
-callback load_adapter(state(), Path :: iodata()) ->
{ok, term(), state()} | {error, term()}.
-callback unload_adapter(state(), Handle :: term()) ->
{ok, state()} | {error, term()}.
-callback apply_adapters(state(), [{term(), float()}]) ->
{ok, state()} | {error, term()}.
%% Backend-specific metadata used by erllama_model:list_models/0
%% beyond what the gen_statem already tracks. The default backend
%% (erllama_model_llama) returns model byte size, total layer
%% count, and the n_gpu_layers value the user passed at load
%% time. erllama_model uses these to compute `vram_estimate_b`.
-callback extra_metadata(state()) ->
#{
model_size_bytes => non_neg_integer(),
total_layers => non_neg_integer(),
n_gpu_layers => integer()
}.
%% Speculative-decoding verifier. Runs PrefixTokens ++ Candidates
%% (truncated to K) through the model with per-position argmax,
%% returns the longest accepted prefix and the model's own next
%% token after it. Mutates and restores the context's KV cells +
%% logits buffer + decode_ready flag so the caller's pre-call view
%% is preserved.
-callback verify(
state(),
PrefixTokens :: [erllama_nif:token_id()],
Candidates :: [erllama_nif:token_id()],
K :: pos_integer()
) ->
{ok, AcceptedCount :: non_neg_integer(), NextToken :: erllama_nif:token_id() | eos,
NewState :: state()}
| {error, term()}.
-optional_callbacks([
kv_pack/3,
kv_unpack/3,
seq_rm/2,
seq_clear/1,
seq_rm_last/2,
seq_rm_last/3,
step/2,
sampler_new/2,
sampler_free/1,
apply_chat_template/2,
embed/2,
set_grammar/2,
configure_sampler/2,
clear_sampler/1,
load_adapter/2,
unload_adapter/2,
apply_adapters/2,
extra_metadata/1,
verify/4,
thinking_signature/3
]).
-type sampler_opts() :: #{
grammar => binary(),
repetition_penalty => float(),
top_k => non_neg_integer(),
top_p => float(),
min_p => float(),
temperature => float(),
seed => non_neg_integer()
}.
-type seq_id() :: non_neg_integer().
-type sampler_ref() :: term().
-type step_op() ::
{prefill, [erllama_nif:token_id()]}
| {decode, sampler_ref()}.
-type step_result() ::
prefilled
| {token, erllama_nif:token_id(), 0 | 1}
%% Thinking-phase token: scheduler detokenises and emits
%% {erllama_token, Ref, {thinking_delta, Bin}} instead of a plain
%% text fragment. Backends without extended-thinking support
%% never emit this variant.
| {thinking_token, erllama_nif:token_id()}
%% Marker that the thinking phase has closed for this decode row.
%% The scheduler resolves a signature via thinking_signature/1
%% (or `<<>>` when the callback is not exported) and sends
%% {erllama_thinking_end, Ref, Sig} before any subsequent token.
| thinking_end
%% Tool-call token: scheduler detokenises and emits
%% {erllama_token, Ref, {tool_call_delta, Bin}} so the downstream
%% can capture the exact bytes the model produced for the call.
%% Backends without tool-call markers never emit this variant.
| {tool_call_token, erllama_nif:token_id()}
%% Marker that the current tool-call span has closed. The
%% scheduler emits {erllama_tool_call_end, Ref, Full :: binary()}
%% carrying the concatenated bytes of every {tool_call_delta, _}
%% it sent for this span, so the downstream does not have to
%% buffer chunks itself.
| tool_call_end
%% Optional inner markers within a tool-call span. When the
%% backend has `tool_call_markers.payload_start` / `payload_end`
%% configured these mark the boundaries of string-payload
%% regions: the scheduler switches the request's sampler from
%% the greedy variant (used for tool-call syntax) back to the
%% request's normal sampler for payload bytes, then back to
%% greedy on payload_close. The token id is included so the
%% marker's bytes still land in the captured tool_call_delta
%% stream (the downstream's exact-replay map needs them).
%% Backends without payload markers never emit these variants.
| {tool_call_payload_open, erllama_nif:token_id()}
| {tool_call_payload_close, erllama_nif:token_id()}.
-export_type([sampler_opts/0, seq_id/0, sampler_ref/0, step_op/0, step_result/0]).
%% Optional. Returns the integrity signature for the most recently
%% closed thinking block on the given sequence. Called by the
%% scheduler once it sees `thinking_end` in a step result, before
%% emitting the corresponding `erllama_thinking_end` message.
%% `Bytes` is the detokenised concatenation of the thinking-phase
%% tokens the scheduler observed for this block; backends that sign
%% the produced text (e.g. an HMAC for tamper detection across
%% turns) can use it directly. The binary returned is opaque to the
%% scheduler and forwarded verbatim to the caller; `<<>>` means "no
%% signature available" and tells the downstream to omit
%% `signature_delta` from its wire output.
-callback thinking_signature(state(), seq_id(), Bytes :: binary()) -> binary().
-type chat_request() :: #{
messages := [chat_message()],
system => binary() | undefined,
tools => [chat_tool()] | undefined
}.
-type chat_message() :: #{
role := binary(),
content := binary() | [map()]
}.
-type chat_tool() :: #{
name := binary(),
description => binary(),
schema => map()
}.
-export_type([chat_request/0, chat_message/0, chat_tool/0]).
-export_type([state/0]).