Changelog¶
All notable changes to Barrel Inference Server are documented here. The format follows Keep a Changelog and this project adheres to Semantic Versioning.
[Unreleased]¶
Added¶
- Autoparser scheduler-pressure instrumentation:
barrel_inference_dirty_cpu_scheduler_utilgauge — per-scheduler busy ratio for both the normal and dirty pools, sampled per/metricsscrape viaerlang:statistics(scheduler_wall_time_all).barrel_inference_chat_apply_duration_secondshistogram — wall-time of the chat apply / render_only / make_params NIF entries.barrel_inference_chat_parse_duration_secondshistogram — wall-time ofchat_parse(the PEG match). The runtime'sbarrel_inference_chatmodule gains aset_observer/1hook the server's metrics module registers itself with at boot; the runtime stays free of any server dependency.-
New operator guidance in
guides/sizing.md: "Autoparser scheduler pressure" subsection plus a commented+SDcpuexample inconfig/vm.argsfor raising the dirty CPU pool when the new gauge shows sustained saturation. -
New Prometheus gauge
barrel_inference_resident_bytes{model=...}, sampled per /metrics scrape viamincore(2). Reports bytes of the model's mmap regions currently faulted in, so operators can watch the working set evolve underweight_residency = lazyandlazy_then_pin_resident. Scrape cost is one extra mincore walk per loaded model (~few ms each on Apple silicon for a 24 B model). -
Fourth
loader.weight_residencymode:lazy_then_pin_resident. Loads withMADV_RANDOM(kernel does not read ahead); once the first request completes the scheduler calls the backend'spin_resident_pages/1to mlock just the working set the prompt selected. Pages outside the set still page in lazily on later prompts but are not pinned. The closest barrel-level approximation of Apple's "per-prompt expert routing" idea (AFM3) for off-the-shelf dense GGUFs. Operator-facing surface is identical to the other modes (manifest field + Modelfile PARAMETER + app env). One-shot per model load; failures are logged and the model continues unpinned. -
New
loader.weight_residencymanifest field (and matching ModelfilePARAMETER weight_residency). Acceptseager(current default, kernel reads ahead),lazy(MADV_RANDOM, the kernel only pages in weights on first touch — lower peak RSS, slightly higher first- token latency, useful for sparse-prompt agent workloads), andpinned(mlock the whole file — predictable jitter on fleet nodes with plenty of RAM). The loader maps the named mode to a(use_mmap, use_mlock, prefetch)triple onmodel_opts. Modelfile parameter wins over the manifest, which wins over the new fleet-wideweight_residency_defaultapp env. Cross-platform mlock caveats (RLIMIT_MEMLOCK on macOS, capability/sysctl on Linux) are operator concerns — failures degrade to a logger warning, not a crash.
Removed¶
- Per-family Erlang tool-call format modules (
qwen-xml,qwen3-coder,dsml,llama-python-tag,llama-pythonic,mistral-tool-calls,mistral-args,phi4-functools,glm45,bare-json),barrel_inference_server_tool_formatbehaviour + registry,src/tool_formats/' source directory,barrel_inference_server_tool_scan' streaming module, andbarrel_inference_server_tool_replay' DETS store. Tool-call parsing routes through llama.cpp'scommon_chat_parse' on the buffered response atbarrel_inference_done' via thebarrel_inference_server_autoparser' bridge. barrel_inference_server_grammar:from_tools/2' tool-grammar generator. Tools requests now decode freely (empty grammar); the autoparser extracts calls at done.from_response_format/1' stays for response_format / format directives.loader.tool_call_format' /loader.tool_call_markers' manifest fields (silently ignored if present in existing manifests; no re-pull needed).- `tool_call_formats' app env (silently ignored if set).
Changed¶
- Tool-call parsing moves from engine-side per-token marker capture
to llama.cpp's
common_chat_parse' on the buffered response atbarrel_inference_done'. HTTP wire format unchanged. - Pipeline renders all chat / messages requests through
barrel_inference:chat_apply/2' (autoparser), with the legacyapply_chat_template/2' kept only as a fallback for backends that don't support chat at all. TheParamsRef' is carried admit -> done via` (4-tuple, was 3-tuple).
Changed¶
- Tool-call format families now live under
apps/barrel_inference_server/src/tool_formats/and the registered set is the single ordered list inapps/barrel_inference_server/include/barrel_inference_server_tool_formats.hrl(macro?BARREL_TOOL_FORMAT_FAMILIES). Adding a new family is now exactly two files: one new module in the subfolder, one new line in the include list. The behaviour gains four new callbacks:family_name/0,detect/1(chat-template predicate + marker pair), and optionalpayload_markers/0(currently onlymistral-argsships extras);parse_all/1andpost_parse_mode/0are also declared as optional callbacks (the marker-less post-parse path). Both the registry map (barrel_inference_server_tool_format:formats/0) and the detection dispatch (barrel_inference_server_tool_format:detect/1) derive from the include macro; thedefault_tool_call_formats/0hand-rolled map and the inlineCandidates/SpecialCases+is_*_template/1predicates inbarrel_inference_server_models:detect_tool_call_format/1(plusmaybe_add_payload_markers/2) are gone. No observable runtime behaviour change; no manifest / config / CI shape change. Thetool_call_formatsapp env merge for operator-supplied custom families is preserved.
Added¶
- New
glm45tool-call format family. Covers zai-org / THUDM GLM-4.5, GLM-4.5-Air, and GLM-4.6 (wire-identical across the three; same tokenizer IDs 151352..151359 for the eight tool / arg markers, same chat-template emission block). The wire shape is the XML body<tool_call>NAME\n<arg_key>K</arg_key>\n<arg_value>V</arg_value>... </tool_call>where the function name lives on the first line and each argument is a<arg_key>/<arg_value>pair. All eight markers are SINGLE tokens in the tokenizer, so the family uses the engine's native marker capture path (qwen3-coder lineage) without any handler change: the engine emitsbarrel_inference_tool_call_endwith the captured body andparse/1extracts the name and arg map. Values are JSON-decoded when they round-trip (numbers, booleans, arrays, objects, quoted strings) and kept as raw binaries for bare unquoted strings, mirroring qwen3-coder'scoerce/1, since the GLM template renders non-string values viatojson(ensure_ascii=False)but bare strings unquoted.canonicalise/1round-trips message history bit-exact. Auto-detection at pull time keys on<tool_call>AND<arg_key>; qwen3-coder shares<tool_call>but uses<function=, so the two predicates are mutually exclusive on real templates. GLM-4.7 ships a different first-line layout and is intentionally NOT covered; futureglm47family. - New
phi4-functoolstool-call format family. Covers Microsoft Phi-4-mini-instruct and Phi-4-multimodal-instruct, which emit calls asfunctools[{"name":...,"arguments":...}, ...]— literal ASCII markers (not control tokens), JSON array body. The end marker]is a single common token, so the engine'smap_marker/2would prematurely close the span on any nested]in an argument value; the family therefore uses the marker-less post-parse path (PR #22's mechanism) with a string-aware bracket-depth walker that finds the OUTER]matching the leading[, ignoring]inside JSON strings and inside nested arrays. The parser tolerates surrounding prose, accepts bothargumentsandparameterskeys (some fine-tunes use the latter), and defaults missing arguments to#{}. The handlers'maybe_post_parse_pythonic/1is generalised tomaybe_post_parse/1with a catch-all_Modeclause so it dispatches on ANY non-nonepost-parse mode (pythonic,functools, ...); the shippedllama-pythonicfamily is behaviour-unchanged. Auto-detection at pull time keys on the presence offunctools[AND the<|tool|>declaration-block marker — both are required to avoid false-positiving on prose templates that mention thefunctoolsPython library. (The larger Phi-4 14B model does NOT support tool calling per Microsoft; this family coversPhi-4-mini-instructandPhi-4-multimodal-instructonly.) - New
llama-pythonictool-call format family. Covers the Llama 3.2 Instruct (1B, 3B) and Llama 3.3 70B Instruct zero-shot wire shape, which is a Python call-list[func1(arg1='val1', arg2=True), func2(...)]terminated by<|eot_id|>- NOT the Llama 3.1<|python_tag|>{json}<|eom_id|>envelope. The existingllama-python-tagfamily stays valid for Llama 3.1 and for 3.3's built-in-tools sub-mode; the new family is the zero-shot path for 3.2 and 3.3. The parser is tolerant of single AND double quoted strings, Python literals (True/False/None) AND JSON equivalents (true/false/null), nested lists and dicts (string OR identifier keys), integers and floats including negatives and scientific notation, and an optional trailing<|eot_id|>literal.canonicalise/1round-trips through Python literals so a captured + replayed call is byte-stable. The family is marker-less: there is no single-token start marker suitable for the engine'smap_marker/2(a bare[would false-positive on prose and code blocks), so it opts into a new post-parse capture path in the chat / messages handlers via apost_parse_mode() -> pythoniccallback. Atbarrel_inference_donethe handler runs the family'sparse_all/1on the accumulatedbuf_textand injects the parsed calls as captured_calls;buf_textis reset so the response does NOT also emit the raw bracket list as content. Auto-detection at pull time picksllama-pythonicfor chat templates that mention pythonic format AND carry<|eot_id|>but NOT<|python_tag|>(the 3.1 signature). Two CT cases pin both directions. Streaming per-call SSE deltas are NOT in scope for this family - pythonic does not allow reliable partial-call boundaries; the handler emits onetool_callsblock at done, with intermediate text deltas streaming normally while the model is generating.
Fixed¶
mistral-tool-calls,llama-python-tag, anddsmlparse the marker-stripped shape that real backends produce. The NIF detokenizer runs withllama_token_to_piece(..., /*special*/ false), which drops control-token markers from the capturedFullBin; the three families above all split on a marker that is itself a control token ([TOOL_CALLS],<|python_tag|>,<|tool▁sep|>) and so were silently broken on a real backend, returning{error, no_markers}formistral-tool-callsandllama-python-tag, and parsingfunctionNAMEas the function name fordsml. Each family'sparse/1now accepts both the canonical marker-present shape AND the marker-stripped shape, mirroring the tolerance pattern shipped with themistral-argsfamily. Three small shared helpers (strip_prefix/2,strip_suffix/2,split_at_first_brace/1) are lifted intobarrel_inference_server_tool_formatso families don't reinvent them. Behavioural note fordsml: in the marker-stripped path the literalfunction' type-prefix text is always stripped (the canonical wire reserves it), so a user-defined function whose name literally starts withfunction' (e.g.functionGetData) loses that prefix on the marker-stripped path; this is documented and pinned by an eunit test.
Added¶
- New
mistral-argstool-call format family. Covers the Mistral tekken-tokenizer wire shape ([TOOL_CALLS]<name>[ARGS]<json-args>, repeated per call, terminated by</s>) used by Devstral-Small-2 (2507, 2512), Mistral-Small-3.1 / 3.2, Magistral, Ministral, and recent Codestral. The shippedmistral-tool-callsfamily parses the older JSON-array shape ([TOOL_CALLS][{...}]) and produced garbledunknown {}calls for these models. Auto-detection at pull time disambiguates the two shapes by requiring[ARGS]to appear AFTER[TOOL_CALLS]in thechat_template(mirroring theis_qwen3_coder_template/1disambiguation pattern); a classic Mistral template, or a template that documents[ARGS]only in an instructions block before[TOOL_CALLS], still detects asmistral-tool-calls. The parser is tolerant of both the canonical wire shape and the real-backend captured shape (control-token markers are dropped by the engine'sspecial=falsedetokenization, soparse/1seesname{json}with no marker bytes) and rejects truncated captures (empty args region) so a fake({})tool use can never reach the caller. Detection also auto-setspayload_start=[ARGS]formistral-argsso the args region uses the request's normal sampler instead of the greedy syntax sampler (without it the model tends to lock onto[TOOL_CALLS]after the args close and spam empty calls). Parallel tool calls round-trip via the engine's span-split on repeated start markers.
Changed¶
parse_full_bin/2in bothbarrel_inference_server_h_chatandbarrel_inference_server_h_messagesnow drops a tool call when the family parser returns{error, empty_args}(a clearly-truncated capture). The previous fallback toparse_tool_call_to_map/1would coerce an empty body into a fakeunknown({})tool use surfaced to the caller. Real parse errors (invalid JSON, non-object args, etc.) still fall through to the legacy heuristic.- Qwen3-Coder tool-call format family (
qwen3-coder). Qwen3-Coder (qwen3moe) emits tool calls in a nested-XML shape (<tool_call><function=NAME><parameter=P>val</parameter></function></tool_call>), not the Qwen2.5 JSON-in-tags shape theqwen-xmlfamily handles, so its calls previously failed to parse (no usable arguments). Newbarrel_inference_server_tool_format_qwen3_coderrenders the nested tool block and parses the nested calls (parameter values JSON-decoded when they round-trip, kept as raw strings otherwise). Pull-time detection (detect_tool_call_format/1) now picksqwen3-coderoverqwen-xmlwhen the template contains<function=(both share the<tool_call>markers), so future Qwen3-Coder pulls map correctly. - Proactive idle-model eviction under memory pressure:
barrel_inference_server_model_evictorimplements the engine'sbarrel_inference_model_evictorbehaviour, so the engine scheduler (withunload_models_under_pressure => true) can unload the least-recently-active idle model when cache eviction cannot relieve sustained pressure. Keepalive gainsunload_idle_sync/1, which re-checksactive = 0atomically inside the gen_server and returnsbusy(without unloading) if a request started since the candidate snapshot, so a model is never unloaded mid-request. The idle-model listing + registry wait used by the loader fit-check are shared viabarrel_inference_server_memory:idle_models/0andwait_unloaded/1. - Memory-aware model loading (opt-in via
memory_aware_loading => true). Before a model loads, the loader estimates its resident footprint (mmapped weights from the GGUF file size + the f16 KV cache at the configured context, sized onhead_count_kvso grouped-query attention is not overestimated) and compares it against available memory (the most restrictive of the GPU VRAM probe and the system memory probe). If it would not fit, the least-recently-active idle model is unloaded synchronously (waiting until it clears the registry) and the fit is re-checked; when nothing idle can be freed the load is rejected with 503model_would_oominstead of letting llama.cpp OOM the box. Keepalive now trackslast_active_msper model (exposed instatus/0) to pick the unload victim. Off by default: the footprint-vs-free-memory comparison is approximate, so it is enabled only on memory-bound multi-model deployments.model_load_memory_margin_b(default 1 GiB) sets the headroom kept free above the estimate. - Static system+tools prefix is checkpointed and pinned once per tool set. When a
request carries tools, the pipeline computes the verified end-of-tools token offset
(the longest common token prefix of a head-only render and the full render - no
template-specific marker stripping needed) and forwards it as
Params.prefix_checkpoint_len, so the engine writes + pins anagent_prefixKV checkpoint there. The big static prefix is then prefilled once and reused warm across turns and even fresh sessions, and survives cache eviction. The head render is memoized per transformed-head identity in a public ETS table. Verified live: a fresh-session second request reused 5552/5566 prompt tokens via the pinned prefix after a full GC dropped every unpinned row.
Fixed¶
- Tool/chat requests no longer hang 60-180 s or crash the model. The hang was the
sticky-seq admission wedge: with the engine's 1-sequence default a pinned
session blocks every other session. Fixed by enabling
admission_on_full = error(a full pool returns a fast retryable 503/529 instead of blocking) and by raising the seq pool. Crucially, the loader now setscontext_opts.kv_unified = true: llama.cpp otherwise splitsn_ctxinton_ctx / n_seq_maxper sequence, so raisingn_seq_max(to 4 by default for concurrency) would have cut a 32768 context to ~8192 and made large agent promptsdecode_failed(crash-loop). With the unified KV cache a single request may use the fulln_ctxwhile up ton_seq_maxsequences share that buffer - concurrency and large context at the same KV memory. A sharedbarrel_inference_server_models:resolve_n_seq_max/1(precedenceparameters.num_seq_max>loader.n_seq_max> 4) drives both the engine seq pool and admission concurrency (pool_policy_for/1) so they never drift; existing models get the default without re-pulling. /api/showreports the context the model actually loads with (honours aparameters.num_ctxoverride set via/api/edit) instead of the raw manifestcontext_size, which left the override invisible. The loader resolvesn_ctxthrough the same newbarrel_inference_server_models:effective_context_size/1(precedenceparameters.num_ctx> manifestcontext_size, capped bymax_context_size) so the reported and loaded contexts never drift.
Added¶
- Tool calling on
tool_choice = autouses native prompting + a tolerant streaming text parser (Ollama-style);required/namedand a per-modelloader.tool_mode = grammaropt-out use the GBNF grammar (forced, schema-enforced). A model whose manifest declaresloader.tool_call_format+ validloader.tool_call_markers, implements the optionalrender_prompt/2callback, and is not pinned totool_mode = grammarrenders tools in its own format and free-decodes;barrel_inference_server_tool_scanthen extracts tool calls from the generated text (configured markers, a generic<tag>wrapper, or bare JSON), validating the name against the request's tools and falling back to content otherwise.render_prompt/2ships forqwen-xml,dsml(DeepSeek),llama-python-tag, andmistral-tool-calls. The parser is bounded (capped holdback + region buffer) and does not enforce argument schemas - usetool_mode = grammar/requiredfor that. - Parallel tool calls. The model can emit several tool calls in one generation;
all three handlers (
/v1/messages,/v1/chat/completions,/v1/responses) accumulate them and surface Ntool_use/tool_calls/ function-call items (streaming and non-streaming).parallel_tool_calls = false(and Anthropictool_choice.disable_parallel_tool_use) caps the turn to the first call. Server-side executor calls in one turn run concurrently via a coordinator (barrel_inference_server_tool_batch) and re-infer once; a mixed batch runs the server calls and continues the turn (client calls deferred to that continuation), so a turn never both continues and finishes. - Embeddings support for embedding GGUFs. The pull pipeline detects embedding
models from GGUF metadata (
barrel_inference_server_gguf:is_embedding_model/1: a declared*.pooling_type, or a bidirectional-encoder architecture likebert/nomic-bert/jina-bert/gte) and marks the manifestloader.embeddings. The loader then opens the context in embeddings mode (context_opts.embeddings), so/v1/embeddingsand/api/embedreturn vectors instead of 501. A ModelfilePARAMETER//api/editcan setembeddingsexplicitly. Embedding models are embeddings-only (chat to them errors). - Default model aliases
fast->coder-7b:mainandbig->qwen3-coder:30b.
Fixed¶
- Pulled manifests cap the default context at 32768 instead of baking the model's
full native context (e.g. 262144). A large-context model no longer allocates tens
of GB of KV by default and trips
system_memory_high_watermark; raise it per-model via/api/edit num_ctxor the server-widemax_context_size. - Heavy tool-grammar requests no longer crash-loop.
engine_call_timeout_msis raised to 120000 so a large model plus a large MCP tool grammar can compile and prefill during admission, and a timed-out engine worker is now killed instead of left running. Newbarrel_inference_engine_admit_duration_secondshistogram (labelled by opinfer/continue) makes admission latency observable, with a slow-admit warning log. pullnow registers a manifest even when the client disconnects or the request times out mid-download. Persistence moved off the HTTP handler into a supervised per-pull coordinator (barrel_inference_server_pull), so a completed download is never orphaned. Previously a multi-GB download could outrun cowboy'sidle_timeout, the handler died before the fetch finished, and the blob landed in the cache with no manifest (solistshowed nothing).- The non-streaming pull path returns
504on timeout while the download continues in the background, rather than losing the manifest. barrel_inference_server_models_store:write_atomic/2returns{error, _}on an unwritable cache dir instead of crashing.- CLI
barrel-inference pullexits non-zero when the stream reports an error (was always0).
Barrel Inference 0.5.0 + tool-call exact-replay¶
- Bumped to Barrel Inference 0.5.0 (
{barrel_inference, "0.5.0"}inrebar.config). v0.5 exposes per-modeltool_call_markers, the{tool_call_delta, _}/barrel_inference_tool_call_endstreaming wire, greedy-on-syntax sampling, sticky-seq KV reuse (session_idoninfer/4,end_session/2), and theprefill_only/3cache- warming primitive. loader.tool_call_markersplumbed from the manifest into the Config map passed tobarrel_inference:load_model/2, mirroring the existingthinking_markerspath. Required keysstart/end; optionalpayload_start/payload_end.- New
barrel_inference_server_tool_formatbehaviour and registry. Each model family ships a module implementingparse/1(FullBin ->#{name, arguments}) andcanonicalise/1(the reverse). The registry resolves a canonical model id via the manifest'sloader.tool_call_formatfield. - Five built-in format families shipped in the default registry, covering the major open-weights backends:
qwen-xml(Qwen3 / Qwen2.5:<tool_call>{...}</tool_call>). Tolerates Hermes-style stringarguments.dsml(DeepSeek-V3 / R1:<|tool▁call▁begin|>function<|tool▁sep|>NAME\n\``json\n{...}\n```<|tool▁call▁end|>`). Tolerates batch wrapper, missing type prefix, missing fence.llama-python-tag(Llama 3.1 / 3.2 / 3.3:<|python_tag|>{"name":..., "parameters":...}<|eom_id|>). Acceptsargumentsas well asparameters.mistral-tool-calls(Mistral / Mixtral v3:[TOOL_CALLS][{"name":..., "arguments":...}]</s>). Returns the first call from a multi-call array; multi-call extraction is a documented follow-up.bare-json(fallback for models that emit raw JSON without delimiters).- New
barrel_inference_server_tool_replayDETS-backed exact-replay store (supervised gen_server). Public ETS table for the O(1) hot-path read; sibling DETS file under<cache_root>/replay/replay.detspersists writes across restarts; periodic gc evicts rows past the TTL. Configuration knobs:tool_replay_dir,tool_replay_ttl_ms(default 30 days),tool_replay_gc_interval_ms(default 1h). All optional with sensible defaults. - Both
/v1/messagesand/v1/chat/completionsconsume the v0.5 tool-call wire when the model hastool_call_markersconfigured: everybarrel_inference_tool_call_endtriggerstool_format:parse/2, a freshtoolu_...id is minted, the parsed JSON + rawFullBin+ model id are persisted in the replay map, and the corresponding Anthropic SSE frames (content_block_start/input_json_delta/content_block_stop) or OpenAIchat.completion.chunkwithtool_callsare emitted. The legacymode = tool_bufferfirst- byte heuristic stays as the fallback for models withouttool_call_markersset. - Render path in
barrel_inference_server_pipelinewalks the message history beforeapply_chat_template/2and consults the replay map for every priortool_useblock. Outcome lands on the newbarrel_inference_tool_replay_lookups_totalcounter, labelled bymodelandresult(hit/miss/no_format). Byte-exact splice awaits an engine-side ask (return-rendered-string variant ofapply_chat_template/2or a verbatim content-block escape); tracked locally and documented in the asks prompt.
Sticky-seq session id derivation + engine pin¶
- New
barrel_inference_server_session:derive/2that yields a stablesession_idfor every request via a layered chain:x-conversation-idheader >metadata.user_id>base64(sha256(model || first user message bytes)). Stamped onto#barrel_inference_request{}in both handlers' fast phase. Per- request stable id without requiring the SDK to send an explicit conversation header. - Engine pin live:
build_params/1now forwards the derived id onParams.session_idtobarrel_inference:infer/4. The engine pins the seq_id across turns so a continuing conversation truncates- and-prefills in place on warm KV cells instead of restoring from disk. {error, sticky_busy}(two concurrent admits on the same session) maps to 503 with retry-after; the Anthropic handler remaps 503 to 529 so SDKs honour the documented backoff.- Handler
cleanup/1callsbarrel_inference:end_session/2only when the request was cancelled mid-flight (received_done = false). Cleanly-completed turns leave the pinned session alive for cross-turn KV reuse. - Operational note: with sticky pinning enabled, the engine's
context_opts.n_seq_max(default 1) must exceed the expected concurrent-session count. A pinned session occupies a seq even between its turns; withn_seq_max=1and traffic from more than one session, admission deadlocks. Setn_seq_max => Non the model's load config (typical N = 4 or matching the queue'sconcurrency).
Cache-reuse profile (TinyLlama-1.1B, 3-turn conversation)¶
test/barrel_inference_server_real_model_SUITE.erl:multi_turn_cache_delta_profile/1
drives a stable-session three-turn conversation and logs the
per-turn cache_read_input_tokens / cache_creation_input_tokens:
| Turn | input | output | cache_read | cache_creation |
|---|---|---|---|---|
| 1 | 21 | 32 | 0 | 53 |
| 2 | 73 | 32 | 0 | 105 |
| 3 | 125 | 32 | 64 | 93 |
Turn 2 sees zero sticky reuse even with Params.session_id pinned.
The chat-template re-renders the first user turn differently in a
multi-turn context, so the engine's strict-prefix check fails and
admits cold. Turn 3 catches up via the disk cache (read=64).
This rules out prefill_only/3 server-side cache warming
(originally PR 8): the bottleneck is token-level prefix
divergence from the chat template, not lack of an explicit
parent_key hint. The engine's natural longest-prefix walk on
admit already finds every available reuse row; an explicit
prefill_only call would compute the same prefix-match
and arrive at the same read count. PR 8 is closed as wontfix.
The leverage point is upstream: a chat-template rendering that
keeps leading-turn bytes stable across single- and multi-turn
calls, OR an engine-side primitive that splices the prior turn's
stored tokens verbatim into the new prompt (effectively the
verbatim-content escape already proposed for tool-call replay).
Captured in /Users/benoitc/Projects/barrel_inference_anthropic_support_prompt.md.
Barrel Inference 0.6.0: caller-asserted continuation (continue/3)¶
The leverage-point ask above landed upstream as
barrel_inference:continue/3: the caller passes (Model, SuffixTokens,
Opts), the engine prefills only the suffix on top of the
session's stored tokens without verifying the prefix. Two-PR
integration:
- Bump
rebar.configto Barrel Inference 0.6.0. The new surface also carriescache_hit_kind = continuationto make the call-path distinguishable from engine-verifiedstickyreuse in Stats. - New
barrel_inference_server_session_state(supervised gen_server + public ETS) caches{Model, SessionId} -> committed_tokens. No disk persistence; restart drops the count and the next turn falls back to a fullinfer/4. - Pipeline
accept_tokens/2arms a continuation slice when a prior count is on file:lists:nthtail(N, NewTokens)becomes the suffix passed tobarrel_inference:continue/3. First-turn / no-state requests still take theinfer/4path. {error, no_session}fromcontinue/3(TTL eviction, server restart, end_session-from-cancel) clears the stale local state and retries with the full token list viainfer/4.- Both handlers stash
committed_tokensfrombarrel_inference_doneStats;maybe_end_session/1on cancel-mid-flight clears the local entry alongside the engine's.
Profile against TinyLlama-1.1B with continue/3 live:
| Turn | input | output | cache_read | cache_creation |
|---|---|---|---|---|
| 1 | 21 | 32 | 0 | 53 |
| 2 | 73 | 32 | 53 | 52 |
| 3 | 125 | 32 | 105 | 52 |
Every turn after the first reuses the predecessor's entire
committed state. cache_creation collapses to roughly the new
tail plus the generated output. The
multi_turn_cache_delta_profile/1 CT case now asserts Read2 > 0
and Read3 > Read2 so a slicing or state regression fails the
build.
Risk: the slice is optimistic - continue/3 does not
verify that the suffix is the correct continuation of the
engine's stored prefix. If a model's chat template re-renders
prior turns differently across turns (different role-marker
bytes), the model emits garbage tokens on the continuation path.
The cache_hit_kind = continuation reported in Stats makes this
diagnosable. TinyLlama is stable; production models need a
per-model test against multi_turn_cache_delta_profile/1 before
relying on continuation.
[0.1.0] - 2026-05-11¶
Initial public release. OpenAI-, Anthropic-, and Ollama-compatible
HTTP server on top of barrel_inference.
OpenAI surface¶
POST /v1/chat/completions(streaming + non-streaming)POST /v1/completionsPOST /v1/embeddingsGET /v1/models[/:id]with alias passthrough- Tool / function calling via grammar-constrained sampling. Tool
arrays converted to JSON Schema then to GBNF and passed as the
grammarfield onbarrel_inference:infer/4. Tool-call output buffered and emitted as one finaltool_callsframe. response_format(text,json_object,{type: "json_schema", json_schema: {schema: ...}}). All three compile to GBNF.
Anthropic surface¶
POST /v1/messageswith named SSE events (message_start,content_block_start,content_block_delta,content_block_stop,message_delta,message_stop). No[DONE]sentinel.- Tool calling buffered as one
content_block_*frame. thinkingparameter recognised; reasoning tokens flow asthinking_deltaevents.
Ollama surface¶
POST /api/generate(streaming NDJSON / non-streaming). Emptyprompttriggers a preload returning{done: true, done_reason: "load", load_duration: N}.POST /api/chat(same semantics over messages).POST /api/embed+POST /api/embeddings(legacy single-prompt).POST /api/pullwith HF, Ollama-registry, HTTPS, andfile://sources. NDJSON progress:pulling manifest->pulling sha256:...with rate-limited byte counts ->verifying sha256 digest->writing manifest->success.GET /api/tags,POST /api/show,POST /api/copy,DELETE /api/delete,POST /api/create(withFROM,PARAMETER,SYSTEM,TEMPLATEdirectives),POST /api/search,GET /api/ps,GET /api/version.keep_aliveparsing: integer seconds, duration strings ("5m","30s","1h"),0to unload immediately,-1/ negative to keep loaded forever.0triggers a synchronous unload so the response is a real acknowledgement.format: "json"andformat: {schema}for structured output. Both compile to GBNF via the same path the OpenAIresponse_formatuses.
Registry¶
- Models stored under
<cache_root>/manifests/<name>/<tag>.json, blobs deduplicated under<cache_root>/blobs/sha256-<hex>.gguf. - GGUF metadata reader (
barrel_inference_server_gguf, pure Erlang, no NIF). Extracts architecture, family, parameter size, quantisation, context length, embedding length, chat template at pull time. Stored verbatim in the manifest. - Manifest Modelfile overrides:
system,template,parameters(whichloadermerges into thebarrel_inference:load_model/2opts).
Inference plumbing¶
- Per-model loader:
barrel_inference_server_loaderspawns a monitored worker forbarrel_inference:load_model/2so the gen_server stays responsive while a load is in flight. Subscribers receive{barrel_inference_load_progress, ModelId}every 2 s and{barrel_inference_load_done, ModelId, ok | {error, _}}exactly once. - Pipeline forwards load progress as
{pipeline, loading, _}; chat handlers emit: loading\n\nSSE comments and Anthropicevent: pingevents so clients see activity during multi-second loads. - Per-model keepalive (
barrel_inference_server_keepalive) with active request counter. Eviction timer only arms when active count reaches zero, so long generations never trigger a mid-stream unload. - Per-model FIFO semaphore queue with
pool_exhaustedreturning concurrency,depth,timeout_msconfigurable per model.- Cancel-on-disconnect: TCP close fires
terminate/3, which callsbarrel_inference:cancel/1, releases the queue slot, kills the pipeline worker. - Cowboy listener
idle_timeoutbumped to 30 min (configurable via{idle_timeout_ms, _}) so long fetches / loads do not get closed at cowboy's default 60 s. - Loader
manifest_to_config/1capscontext_sizeatmax_context_size(default 4096) so models advertising 128 K contexts in their GGUF do not OOM at load time. - Pipeline wraps every call into
barrel_inferencein try/catch; a crashing model gen_statem returns a 500 JSON envelope or an SSE / Anthropic error frame instead of killing the cowboy request process.
Observability¶
instrument-backed metrics with Prometheus text format at/metrics. Counters, gauges, and histograms for requests, prefill / generation latency, tokens, queue depth, active streams.GET /health(liveness) andGET /health/ready(readiness).X-Request-IDpropagation: echoed if present, minted asreq_<int>if absent.- Per-request access log via a Cowboy
stream_handler.
CORS¶
- Off by default. When set to a map, full preflight handling +
Access-Control-Allow-*headers on every response. Allow-list andmax_ageconfigurable.
CLI¶
barrel_inferenceescript (rebar3 escriptize->_build/default/bin/barrel_inference). Subcommands:pull,list(ls),ps,show,rm(delete),copy(cp),search,run,embed,unload,version,help.- Talks to the daemon over HTTP. Base URL via
BARREL_INFERENCE_HOST(defaulthttp://127.0.0.1:8080).
Body-shape caps¶
max_messages(default 1024),max_tools(default 128),max_request_body_bytes(default 1 MiB),max_embedding_inputs(default 256). Bad inputs return 400 before the slow phase.
Tooling¶
- erlfmt + rebar3_lint + dialyzer + xref integration with project-specific rule overrides.
- 127 eunit + 106 CT cases. CT real-model suite (
LLAMA_TEST_MODELgated) for end-to-end smoke against an actual GGUF. - OpenAPI 3.1 spec at
openapi.yaml. - GitHub Actions CI (format, lint, xref, dialyzer, build matrix ubuntu + macos, eunit, ct).
Out of scope for 0.1¶
POST /api/push(publish to registry).- Multi-modal inputs (images, audio).
- Modelfile
ADAPTER(LoRA),MESSAGE,LICENSEdirectives. - On-the-fly quantisation.
- Garbage collection of orphan blobs (deleting a manifest leaves the blob in place even if no other manifest references it).