thinwrap_llm
OpenAI, Azure OpenAI, Anthropic, Google Gemini, AWS Bedrock, Mistral, +12
Thinwrap's three Hex packages are written in Erlang and run on the BEAM, so a Gleam project on the Erlang target can depend on them directly. There are no typed Gleam bindings: you declare the functions you call with @external and decode what comes back. The claims below hold for Gleam only.
Add the packages you need, plus gleam_erlang for the atoms their config and input maps are keyed by. The packages are built with rebar3, so it has to be on PATH when Gleam compiles them; the official ghcr.io/gleam-lang/gleam:*-erlangimages include it.
gleam add thinwrap_location thinwrap_notifications thinwrap_llm gleam_erlang{ok, X} and {error, Y} are Gleam's Ok(X) and Error(Y) at runtime, so the external returns a Result directly. Config and input are atom-keyed maps built as Dynamic; result fields come back through a decoder. Switching providers changes the config, not the input or the decoders. Prints 35.1 km, 39 min.
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/erlang/atom
import gleam/float
import gleam/int
import gleam/io
import gleam/list
// No typed bindings ship with the package: declare what you call.
@external(erlang, "thinwrap_location", "route")
fn route(config: Dynamic, input: Dynamic) -> Result(Dynamic, Dynamic)
// The Erlang API takes atom-keyed maps; these two helpers build them.
fn key(name: String) -> Dynamic {
atom.to_dynamic(atom.create(name))
}
fn obj(fields: List(#(String, Dynamic))) -> Dynamic {
fields
|> list.map(fn(field) { #(key(field.0), field.1) })
|> dynamic.properties
}
fn point(lat: Float, lng: Float) -> Dynamic {
obj([#("lat", dynamic.float(lat)), #("lng", dynamic.float(lng))])
}
pub fn main() {
// OSRM's public demo server needs no key, so this runs as-is.
let config =
obj([
#("provider", key("osrm")),
#("base_url", dynamic.string("https://router.project-osrm.org")),
])
let input =
obj([
#(
"waypoints",
dynamic.list([point(52.52, 13.405), point(52.3906, 13.0645)]),
),
])
let summary = {
use meters <- decode.field(
atom.create("total_distance_meters"),
decode.float,
)
use seconds <- decode.field(
atom.create("total_duration_seconds"),
decode.float,
)
decode.success(#(meters, seconds))
}
let failure = {
use code <- decode.field(atom.create("code"), atom.decoder())
use message <- decode.field(atom.create("message"), decode.string)
decode.success(atom.to_string(code) <> ": " <> message)
}
case route(config, input) {
Ok(result) ->
case decode.run(result, summary) {
Ok(#(meters, seconds)) ->
io.println(
float.to_string(float.to_precision(meters /. 1000.0, 1))
<> " km, "
<> int.to_string(float.round(seconds /. 60.0))
<> " min",
)
Error(_) -> io.println("unexpected result shape")
}
Error(err) ->
case decode.run(err, failure) {
Ok(text) -> io.println(text)
Error(_) -> io.println("unexpected error shape")
}
}
}Create atoms from your own literals only, never from user input: atoms are never garbage-collected.
OpenAI, Azure OpenAI, Anthropic, Google Gemini, AWS Bedrock, Mistral, +12
Google Maps Platform, Mapbox, HERE, ESRI ArcGIS, TomTom, OSRM (self-hosted)
SendGrid, AWS SES, Mailgun, Postmark, Resend, Brevo (Sendinblue), +29
The unified surface includes only features that ≥90% of providers natively support. Sub-baseline fields stay accessible via thepassthrough key (body deep-merged into the request body; your values win), and the vendor's answer rides inraw. Cross-language parity with the other packages is a design goal — not a hard release gate.
An operation failure is an Error holding a map with acode atom (auth_failed,rate_limited, invalid_request, …) and amessage string. Misusing the API — an unknown key, a wrongly typed value, a provider that doesn't serve the operation — raises instead, before any request, and crashes the calling process. The wrapper holds no state and performs no automatic retry: the rawRetry-After header and its parsed seconds ride in the error's cause. To bring your own HTTP client, write a module with pub fn request(req, opts) and pass it as thetransport tuple.