thinwrap_llm (thinwrap_llm v1.1.0)
View SourceChat completions (with streaming) and embeddings across 18 LLM providers — one normalized surface, zero dependencies.
Switching vendor is a change of the config's provider + the input's model
only; the input and output shapes are identical across every provider.
Config = #{provider => openai, api_key => <<"sk-...">>},
{ok, #{message := #{content := Text}, usage := #{total_tokens := N}}} =
thinwrap_llm:complete(Config, #{
model => <<"gpt-4o-mini">>,
messages => [#{role => user, content => <<"Say hi in one word.">>}]
}).Streaming
stream/4 folds a fun over the normalized deltas in the calling process
as they arrive. The library starts no long-lived processes; the default
thinwrap_llm_httpc transport runs each streaming call through one helper
process that ends with the call (a transport of your own is unaffected):
{ok, Text} = thinwrap_llm:stream(Config, Input,
fun(Delta, Acc) -> [Acc, maps:get(content_delta, Delta, <<>>)] end, []).A stream that is cut off before the provider's terminal event, or a 2xx
answer that is not text/event-stream, is {error, #{code := provider_unavailable}} rather than ending as if it were complete. Deltas
already delivered to the fun stay delivered. usage arrives once, on the
terminal delta.
Two kinds of failure
- Operation failures — a vendor error, a transport failure, request data
the vendor would reject — come back as
{error, connector_error()}, a plain map whosecodeyou branch on. - Misuse — a missing or unknown config/input key, a value of the wrong
type, an enum atom outside its set, a provider that lacks the operation —
raises
error({badarg, Description})before any HTTP. Folding those into{error, _}would make a bug in the calling code look like bad end-user input or, worse, a retryable outage.
Usage
usage counts every billed token: input_tokens includes cache reads and
writes (Anthropic, Bedrock) and tool-use prompt tokens (Gemini),
output_tokens includes thinking tokens (Gemini), and total_tokens is the
vendor's total, or input + output when it reports none.
Tool loops and provider_metadata
Some providers need opaque state from a response sent back on the next turn:
Gemini 3 rejects a replayed function call without its thought signature, and
Anthropic keeps its thinking blocks only if they are replayed verbatim.
provider_metadata on a tool call and on the assistant message carries that
state, keyed by provider (#{<<"gemini">> => ...},
#{<<"anthropic">> => ...}). Treat it as opaque and copy it into the next
request together with content and tool_calls. When streaming, it rides on
tool_call_delta.provider_metadata and on the terminal delta. A connector
reads only its own key, so a history survives a provider switch.
Passthrough
Every input accepts passthrough => #{body => map(), headers => map(), query => map()}, forwarded verbatim and overriding what the connector set
(auth included): body is merged shallowly (a key replaces the connector's
whole value), headers are merged, query is appended to the URL. Vendor
extras on the response are in raw.
Summary
Types
raw is the decoded vendor body (JSON null is the atom null).
Provider configuration. provider selects the connector; the other keys
depend on it
The operation-failure value. code is the normalized classification shared
with the sibling libraries; status is the HTTP status when the provider
answered (an error inside a 2xx body or a stream frame keeps the status it
arrived with); cause is a binary-keyed map with the decoded vendor body
under <<"raw">>, plus <<"retryAfter">> (the raw header) and
<<"retryAfterSeconds">> when the provider sent a usable Retry-After.
One part of a multimodal message.
embeddings holds one vector per input, in input order.
One turn. Use content for plain text, or content_parts for multimodal
(content_parts wins when non-empty). provider_metadata on an assistant
turn is the opaque state copied from the result's message.
Provider ids, byte-identical to the sibling libraries' values. The first 15
share one OpenAI-compatible connector; anthropic, bedrock and gemini are
native adapters.
Reasoning effort. low | medium | high, or a binary sent as given (an empty
binary means "unspecified"). The one deliberate carve-out from the ≥90%
baseline rule; providers that cannot map it ignore it.
json_schema takes json_schema_name + json_schema.
One normalized stream delta. Every key is optional and omitted when empty.
usage, finish_reason and the message-level provider_metadata arrive on
the terminal delta; raw is the decoded vendor event the delta came from.
A tool call. arguments is the JSON-encoded arguments text.
provider_metadata is opaque per-provider state keyed by provider id (e.g.
Gemini's thought signature); copy it back unchanged when replaying the call.
A streamed tool-call fragment. Concatenate arguments_delta per index
(the 0-based tool-call ordinal); id and function_name arrive on the first
fragment. Copy provider_metadata onto the assembled tool call.
function needs function_name.
Functions
Run one chat completion.
Create embeddings for input (the OpenAI-float subset: openai,
'azure-openai', openrouter, together, fireworks, mistral,
deepinfra, cloudflare, vllm, ollama, lmstudio).
Stream one chat completion, folding Fun(Delta, Acc) over the normalized
deltas in the calling process as they arrive. Returns the final accumulator,
or the error that ended the stream.
Whether Provider implements Operation.
Types
-type chat_input() :: #{model := binary(), messages := [message()], tools => [tool_definition()], tool_choice => tool_choice(), response_format => response_format(), temperature => number(), top_p => number(), max_output_tokens => integer(), stop => [binary()], reasoning => reasoning(), passthrough => passthrough()}.
-type chat_result() :: #{message := assistant_message(), finish_reason := finish_reason(), usage := usage(), model := binary(), raw := term()}.
raw is the decoded vendor body (JSON null is the atom null).
-type config() :: #{provider := provider(), api_key => secret(), base_url => binary(), headers => #{binary() | atom() => binary()}, anthropic_version => binary(), default_max_tokens => non_neg_integer(), region => binary(), access_key_id => secret(), secret_access_key => secret(), session_token => secret(), transport => thinwrap_llm_transport:transport()}.
Provider configuration. provider selects the connector; the other keys
depend on it:
- the 15 OpenAI-compatible providers —
api_key(optional for the self-hostedvllm,ollama,lmstudio; sent asAuthorization: Bearer, orapi-keyfor'azure-openai'),base_url(required for'azure-openai'andcloudflare, which have no fixed public endpoint),headers; anthropic—api_key(required, sent asx-api-key),base_url,anthropic_version(default<<"2023-06-01">>),default_max_tokens,headers;bedrock—region,access_key_id,secret_access_key(required),session_token,base_url,default_max_tokens,headers;gemini—api_key(required, sent asx-goog-api-key),base_url,default_max_tokens,headers.
Optional on every provider: transport (see thinwrap_llm_transport;
default {thinwrap_llm_httpc, #{}}).
-type connector_error() :: #{code := thinwrap_llm_error:code(), message := binary(), status => integer(), provider_message => binary(), cause => term()}.
The operation-failure value. code is the normalized classification shared
with the sibling libraries; status is the HTTP status when the provider
answered (an error inside a 2xx body or a stream frame keeps the status it
arrived with); cause is a binary-keyed map with the decoded vendor body
under <<"raw">>, plus <<"retryAfter">> (the raw header) and
<<"retryAfterSeconds">> when the provider sent a usable Retry-After.
-type content_part() :: #{type := text, text => binary()} | #{type := image, base64 => binary(), media_type => binary()}.
One part of a multimodal message.
-type embeddings_input() :: #{model := binary(), input := [binary()], dimensions => integer(), passthrough => passthrough()}.
-type embeddings_result() :: #{embeddings := [[float()]], usage := embeddings_usage(), model := binary(), raw := term()}.
embeddings holds one vector per input, in input order.
-type finish_reason() :: stop | length | tool_calls | content_filter | unknown.
-type message() :: #{role := role(), content => binary(), content_parts => [content_part()], tool_calls => [tool_call()], tool_call_id => binary(), name => binary(), provider_metadata => map()}.
One turn. Use content for plain text, or content_parts for multimodal
(content_parts wins when non-empty). provider_metadata on an assistant
turn is the opaque state copied from the result's message.
-type operation() :: complete | stream | embed.
-type provider() ::
openai | 'azure-openai' | openrouter | groq | together | fireworks | deepseek | xai |
mistral | perplexity | deepinfra | cloudflare | vllm | ollama | lmstudio | anthropic |
bedrock | gemini.
Provider ids, byte-identical to the sibling libraries' values. The first 15
share one OpenAI-compatible connector; anthropic, bedrock and gemini are
native adapters.
-type reasoning() :: #{effort := low | medium | high | binary()}.
Reasoning effort. low | medium | high, or a binary sent as given (an empty
binary means "unspecified"). The one deliberate carve-out from the ≥90%
baseline rule; providers that cannot map it ignore it.
-type response_format() :: #{type := text | json_object | json_schema, json_schema_name => binary(), json_schema => map()}.
json_schema takes json_schema_name + json_schema.
-type result(T) :: {ok, T} | {error, connector_error()}.
-type role() :: system | user | assistant | tool.
-type stream_delta() :: #{content_delta => binary(), tool_call_delta => tool_call_delta(), finish_reason => finish_reason(), usage => usage(), raw => term(), provider_metadata => map()}.
One normalized stream delta. Every key is optional and omitted when empty.
usage, finish_reason and the message-level provider_metadata arrive on
the terminal delta; raw is the decoded vendor event the delta came from.
-type tool_call() :: #{id := binary(), name := binary(), arguments := binary(), provider_metadata => map()}.
A tool call. arguments is the JSON-encoded arguments text.
provider_metadata is opaque per-provider state keyed by provider id (e.g.
Gemini's thought signature); copy it back unchanged when replaying the call.
-type tool_call_delta() :: #{index := integer(), id => binary(), function_name => binary(), arguments_delta => binary(), provider_metadata => map()}.
A streamed tool-call fragment. Concatenate arguments_delta per index
(the 0-based tool-call ordinal); id and function_name arrive on the first
fragment. Copy provider_metadata onto the assembled tool call.
-type tool_choice() :: #{mode := auto | none | required | function, function_name => binary()}.
function needs function_name.
Functions
-spec complete(config(), chat_input()) -> result(chat_result()).
Run one chat completion.
-spec embed(config(), embeddings_input()) -> result(embeddings_result()).
Create embeddings for input (the OpenAI-float subset: openai,
'azure-openai', openrouter, together, fireworks, mistral,
deepinfra, cloudflare, vllm, ollama, lmstudio).
-spec stream(config(), chat_input(), fun((stream_delta(), Acc) -> Acc), Acc) -> {ok, Acc} | {error, connector_error()}.
Stream one chat completion, folding Fun(Delta, Acc) over the normalized
deltas in the calling process as they arrive. Returns the final accumulator,
or the error that ended the stream.
Whether Provider implements Operation.