thinwrap_llm
thinwrap_llm for Erlang, Elixir and Gleam — one Hex package, a unified chat API (OpenAI Chat Completions shape) with streaming and embeddings across 18 providers. Zero runtime deps (OTP httpc), stateless, BYO transport.
Erlang constraints
- Runtime dependencies
- Zero runtime dependencies
- BYO HTTP client
- transport behaviour: transport => {Module, Opts}, where Module exports request/2
- Provenance
- Hex registry signatures + lockfile checksums
- Minimum runtime
- Erlang/OTP 27+
- Truly zero dependencies — OTP only: httpc (inets + ssl) is the only HTTP surface, the stdlib json module parses JSON, and AWS SigV4 (SES, SNS, Bedrock) / JWT signing (FCM RS256, APNs ES256) are hand-rolled on crypto and public_key.
- Hex has no build-provenance attestation like npm provenance or PEP 740: the registry signs its index and your lockfile pins each tarball checksum, which proves integrity, not where the package was built.
- Stateless: no supervision tree, ETS tables or application env to configure. The default httpc transport is HTTPS-only and never follows redirects — a 3xx surfaces as an error rather than silently re-sending auth headers to another host.
30-second install
{deps, [{thinwrap_llm, "~> 1.1"}]}.{:thinwrap_llm, "~> 1.1"}gleam add thinwrap_llm gleam_erlang envoy2-minute end-to-end example
One complete/2, every provider. Switching vendor is the config's provider plus the model — the input and result shapes stay identical, and every failure is an error map with acode. Streaming is stream/4, a fold over the deltas. One package, three ways to call it:
Erlang
Paste into a function body or the shell. Reads the key from OPENAI_API_KEY. More on calling it from Erlang: /erlang.
Config = #{provider => openai,
api_key => list_to_binary(os:getenv("OPENAI_API_KEY"))},
Input = #{model => <<"gpt-5.5">>,
messages => [#{role => user, content => <<"Say hi in one word.">>}]},
case thinwrap_llm:complete(Config, Input) of
{ok, #{message := #{content := Text}, usage := #{total_tokens := Tokens}}} ->
io:format("~ts ~B~n", [Text, Tokens]);
{error, #{code := Code, message := Msg}} ->
io:format("~s: ~ts~n", [Code, Msg])
end,
%% Streaming is a fold: your fun runs in the calling process, once per delta.
{ok, ok} = thinwrap_llm:stream(Config, Input,
fun(#{content_delta := Chunk}, ok) -> io:put_chars(Chunk);
(_Delta, ok) -> ok
end, ok).Elixir
Top-level script code (mix run). Reads the key from OPENAI_API_KEY. More on calling it from Elixir: /elixir.
config = %{provider: :openai, api_key: System.fetch_env!("OPENAI_API_KEY")}
input = %{model: "gpt-5.5", messages: [%{role: :user, content: "Say hi in one word."}]}
case :thinwrap_llm.complete(config, input) do
{:ok, %{message: %{content: text}, usage: %{total_tokens: tokens}}} ->
IO.puts("#{text} #{tokens}")
{:error, %{code: code, message: message}} ->
IO.puts("#{code}: #{message}")
end
# Streaming is a fold: your fun runs in the calling process, once per delta.
{:ok, :ok} =
:thinwrap_llm.stream(
config,
input,
fn
%{content_delta: chunk}, :ok -> IO.write(chunk)
_delta, :ok -> :ok
end,
:ok
)Gleam
A complete module. Besides the package it uses gleam_erlang for atoms and envoy to read OPENAI_API_KEY; rebar3 must be on PATH to build the package. More on calling it from Gleam: /gleam.
import envoy
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/erlang/atom
import gleam/int
import gleam/io
import gleam/list
@external(erlang, "thinwrap_llm", "complete")
fn complete(config: Dynamic, input: Dynamic) -> Result(Dynamic, Dynamic)
@external(erlang, "thinwrap_llm", "stream")
fn stream(
config: Dynamic,
input: Dynamic,
fun: fn(Dynamic, acc) -> acc,
acc0: acc,
) -> Result(acc, Dynamic)
pub fn main() -> Nil {
let assert Ok(api_key) = envoy.get("OPENAI_API_KEY")
let config =
obj([#("provider", atom("openai")), #("api_key", dynamic.string(api_key))])
let input =
obj([
#("model", dynamic.string("gpt-5.5")),
#(
"messages",
dynamic.list([
obj([
#("role", atom("user")),
#("content", dynamic.string("Say hi in one word.")),
]),
]),
),
])
case complete(config, input) {
Ok(res) -> {
let reply = {
use text <- decode.subfield(
[key("message"), key("content")],
decode.string,
)
use tokens <- decode.subfield(
[key("usage"), key("total_tokens")],
decode.int,
)
decode.success(text <> " " <> int.to_string(tokens))
}
let assert Ok(line) = decode.run(res, reply)
io.println(line)
}
Error(err) -> io.println(describe(err))
}
// Streaming is a fold: your fun runs in the calling process, once per delta.
let chunk = decode.field(key("content_delta"), decode.string, decode.success)
let print = fn(delta, acc) {
case decode.run(delta, chunk) {
Ok(text) -> io.print(text)
Error(_) -> Nil
}
acc
}
let assert Ok(Nil) = stream(config, input, print, Nil)
io.println("")
}
// The library speaks atom-keyed Erlang maps; these helpers build and read them.
fn key(name: String) -> atom.Atom {
atom.create(name)
}
fn atom(name: String) -> Dynamic {
atom.to_dynamic(atom.create(name))
}
fn obj(fields: List(#(String, Dynamic))) -> Dynamic {
dynamic.properties(list.map(fields, fn(f) { #(atom(f.0), f.1) }))
}
fn describe(err: Dynamic) -> String {
let error = {
use code <- decode.field(key("code"), atom.decoder())
use message <- decode.field(key("message"), decode.string)
decode.success(atom.to_string(code) <> ": " <> message)
}
let assert Ok(line) = decode.run(err, error)
line
}The 15 OpenAI-compatible providers share one connector; Anthropic, Bedrock, and Gemini are native adapters with their own config keys. Samecomplete/2 and stream/4 throughout;thinwrap_llm:supports(Provider, complete | stream | embed)answers at runtime.
Supported providers
| Provider | Operations | Auth | README |
|---|---|---|---|
openai OpenAI | chat, embeddings | bearer | docs |
azure-openai Azure OpenAI | chat, embeddings | header | docs |
anthropic Anthropic | chat | header | docs |
gemini Google Gemini | chat | header | docs |
bedrock AWS Bedrock | chat | aws-sig-v4 | docs |
mistral Mistral | chat, embeddings | bearer | docs |
cloudflare Cloudflare Workers AI | chat, embeddings | bearer | docs |
together Together AI | chat, embeddings | bearer | docs |
fireworks Fireworks AI | chat, embeddings | bearer | docs |
deepinfra DeepInfra | chat, embeddings | bearer | docs |
openrouter OpenRouter | chat, embeddings | bearer | docs |
groq Groq | chat | bearer | docs |
deepseek DeepSeek | chat | bearer | docs |
xai xAI (Grok) | chat | bearer | docs |
perplexity Perplexity | chat | bearer | docs |
vllm vLLM | chat, embeddings | bearer | docs |
ollama Ollama | chat, embeddings | bearer | docs |
lmstudio LM Studio | chat, embeddings | bearer | docs |
Migration paths
The in-process complement to a gateway
Thinwrap LLM is a thin, in-process, zero-egress library — it does not proxy your traffic. It normalizes the request/response shape across vendors so your code stays vendor-neutral; routing, budgets, and observability stay wherever you already run them. Provider-specific request fields ride through passthrough; the decoded vendor body is always on raw.
Streaming
stream/4 folds your fun over the deltas in the calling process and leaves nothing in your mailbox. A stream cut off before the provider's terminal event returns{error, #{code := provider_unavailable}}rather than ending as if it were complete; if your fun raises, the request is cancelled and the exception propagates unchanged.
Embeddings
{ok, #{embeddings := Vectors}} =
thinwrap_llm:embed(#{provider => openai, api_key => Key},
#{model => <<"text-embedding-3-small">>,
input => [<<"hello">>, <<"world">>]}).
%% Vectors is [[float()]] in input orderEmbeddings ship on the OpenAI-float subset (11 of the 18 providers — see the table above).
Cross-language siblings
The TypeScript (/packages/llm-ts), PHP (/packages/llm-php), Go (/packages/llm-go), and Python (/packages/llm-py) packages ship the same API shape and provider set — cross-language parity is a design goal, not a hard release gate.