erlangchain
Erlangchain is a small Erlang library that talks to AI services for you. Your app calls a few functions. The library handles the HTTP requests, the JSON, and the differences between providers. It has no third-party dependencies.
It gives you three things:
- Chat.
llm:chat/1throughllm:chat/6send a conversation to OpenAI, Anthropic, or OpenRouter (opensource). You pick a size (small,big, orfrontier) or name an exact model. You get back text, tool calls, and token usage in one map. You can also send a picture in a user message so the model can look at it. - Image generation.
llm:image(opensource, large, Prompt)asks Hugging Face (fal-ai, FLUX.1-dev) to make a picture. You get the image bytes and a MIME type. The library waits out the queue so your code does not have to. - OpenAI file search.
llm_datasourcecreates a vector store, adds or removes files, and deletes it. Pass that store id intollm:chatso OpenAI can search those files.
json_util is a helper for encoding and decoding JSON.
The point is one Erlang API instead of writing a separate client for each vendor.
| Module | Role |
|---|---|
llm |
chat and image-generation client for OpenAI, Anthropic, OpenRouter, and Hugging Face Inference Providers |
llm_datasource |
create and manage provider-backed vector-store datasources |
json_util |
dependency-free JSON encode/decode |
Install
%% rebar.config
{deps, [
{erlangchain, "~> 4.2.4"}
]}.
Set OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, and/or
HF_TOKEN in the environment (a .env file in the working directory is loaded
automatically if present).
4.2.4 adds token pricing, the claude-opus-5-5 rate, and thinking
blocks from every provider. Chat calls keep the same arguments and the same fields.
cost_usd is added when a price is known. A model with no price still returns
{ok, Response}. A reply includes thinking only when the model sent
thinking blocks, and those blocks are sent back on the next turn.
llm
%% Simple completion (defaults to openai/small):
{ok, #{content := Text}} = llm:chat([#{role => user, content => <<"hello">>}]),
%% Pick provider + size:
{ok, Resp} = llm:chat(openai, big, Messages),
%% Open-source models through OpenRouter:
{ok, Resp} = llm:chat(opensource, small, Messages),
%% Image generation through Hugging Face's fal-ai provider:
{ok, #{image := ImageBytes, content_type := MimeType}} =
llm:image(opensource, large, <<"Astronaut riding a horse">>),
%% Frontier tier, or an exact model slug:
{ok, Resp} = llm:chat(openai, frontier, Messages),
{ok, Resp} = llm:chat("openai", "model-slug", Messages),
%% Tool use — pass tool specs, get back tool_calls to run and feed back:
{ok, #{tool_calls := Calls}} = llm:chat(openai, big, Messages, Tools),
%% OpenAI file search — pass a vector store id before Opts:
{ok, Resp} = llm:chat(openai, big, Messages, Tools, <<"vs_product_docs">>, #{}).
%% Datasource lifecycle — file paths are uploaded and attached as one batch:
{ok, DatasourceId} = llm_datasource:create(openai, <<"Product docs">>),
{ok, #{file_ids := FileIds}} =
llm_datasource:files_add(
openai, DatasourceId, ["docs/guide.pdf", "docs/api.md"]
),
ok = llm_datasource:files_remove(openai, DatasourceId, FileIds),
ok = llm_datasource:delete(openai, DatasourceId).
Messages are maps like #{role => system|user|assistant, content => binary()},
plus #{role => tool_result, tool_use_id => Id, content => Bin} to return tool
output. The frontier models are gpt-6-astra for OpenAI, fable-5 for
Anthropic, and moonshotai/kimi-k3 for the opensource OpenRouter provider.
OpenAI big is gpt-5.6-sol. Anthropic big is claude-opus-5-5 and
Anthropic small is claude-haiku-5-5. The other opensource defaults are openai/gpt-oss-20b
for small and z-ai/glm-5.2 for big. A tier can be replaced with an exact
model slug as a string or binary; provider names also accept atoms, strings, or
binaries.
Alternatively, pass #{model => "provider/model"} in Opts. Datasource is
none or an OpenAI vector store id. Other providers do not support managed
vector-store datasources. See the header of src/llm.erl for the full
message/response shapes. Deleting datasource files detaches them from that
vector store; it does not permanently delete the uploaded OpenAI files.
Reasoning effort
Set reasoning_effort in Opts on any chat call. Leave it out to keep the provider default. An OpenAI big call uses medium when the option is omitted.
llm:chat(openai, small, Messages, [], #{reasoning_effort => high}).
Token cost
Chat adds cost_usd to usage when the model is in the built-in price list.
OpenRouter's own usage.cost is used for opensource when the response
includes it.
{ok, #{usage := #{cost_usd := Dollars}}} =
llm:chat(openai, small, Messages),
{ok, Dollars} = llm:cost(openai, <<"gpt-5.6-luna">>, Usage).
#{pricing => false} in Opts leaves the cost off. #{prices => PriceList}
uses your rates instead of llm_prices:list/0. Rates are USD per 1,000,000
tokens: #{<<"model">> => #{in => 0.20, out => 1.20, cache_read => 0.02}}.
The rules live in llm_pricing. cost_usd is token cost only. Anthropic
cache writes are included: 5-minute writes at 1.25 times the input rate, and
1-hour writes at 2 times the input rate.
Caching
caching can be set on any chat call. It changes the request for Anthropic.
OpenAI and OpenRouter accept it and leave the request as it is.
%% Default. Anthropic sends cache_control with a 5-minute ttl.
llm:chat(anthropic, big, Messages, [], #{}),
%% Keep the cache for 1 hour, or turn it off:
llm:chat(anthropic, big, Messages, [], #{caching => one_hour}),
llm:chat(anthropic, big, Messages, [], #{caching => off}).
An unknown caching value returns {error, {invalid_option, caching}}.
Image generation
llm:image/3 is separate from llm:chat/3. Only this call is supported:
llm:image(opensource, large, Prompt) ->
{ok, #{image := ImageBytes, content_type := MimeType}} | {error, Reason}.
Prompt is a string or binary. image is the encoded file bytes (JPEG or
PNG, not a URL). content_type is the MIME type, such as <<"image/jpeg">>.
This uses black-forest-labs/FLUX.1-dev through Hugging Face's fal-ai
provider and requires HF_TOKEN. Hugging Face often answers immediately with
a request_id instead of an image. Since 0.4.1 the client treats that as a
queued job: it polls until the job finishes or fails, downloads the image,
and only then returns {ok, ...}. The caller does not see the queue.
Other providers and sizes do not implement llm:image/3.
json_util
<<"{\"a\":1}">> = json_util:encode(#{<<"a">> => 1}),
#{<<"a">> := 1} = json_util:decode(<<"{\"a\":1}">>).
License
MIT — see LICENSE.