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"}]}.

2-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

18 providers ship with thinwrap_llm@1.1.0.
ProviderOperationsAuthREADME
openai OpenAIchat, embeddingsbearerdocs
azure-openai Azure OpenAIchat, embeddingsheaderdocs
anthropic Anthropicchatheaderdocs
gemini Google Geminichatheaderdocs
bedrock AWS Bedrockchataws-sig-v4docs
mistral Mistralchat, embeddingsbearerdocs
cloudflare Cloudflare Workers AIchat, embeddingsbearerdocs
together Together AIchat, embeddingsbearerdocs
fireworks Fireworks AIchat, embeddingsbearerdocs
deepinfra DeepInfrachat, embeddingsbearerdocs
openrouter OpenRouterchat, embeddingsbearerdocs
groq Groqchatbearerdocs
deepseek DeepSeekchatbearerdocs
xai xAI (Grok)chatbearerdocs
perplexity Perplexitychatbearerdocs
vllm vLLMchat, embeddingsbearerdocs
ollama Ollamachat, embeddingsbearerdocs
lmstudio LM Studiochat, embeddingsbearerdocs

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 order

Embeddings 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.

Read more