elixir sdk for openrouter. thin, finch-based, ships zero policy.
unofficial. not affiliated with, supported by, or endorsed by the openrouter team. maintained by the san francisco voice company — get in touch at contact@sf-voice.sh.
supports:
- chat completions (openai-compatible) — buffered + sse streaming
- anthropic messages (
/v1/messages) — buffered + sse streaming - embeddings
- text-to-speech (
speak/2) and speech-to-text (transcribe/2) via/chat/completionsaudio modalities (catalog-discoverable models), plus the dedicated/audio/speechand/audio/transcriptionsendpoints for completeness - bearer api keys + oauth 2 pkce primitives
- a hard-coded snapshot of all models + providers, refreshed nightly by ci with an auto-opened pr when openrouter ships drift
retries, exponential backoff, model rotation, and circuit breakers
are intentionally not in this package. compose them yourself via
the OpenrouterSdk.Middleware behaviour.
def deps do
[
{:openrouter_sdk, "~> 0.1.0"}
]
end# config/runtime.exs
config :openrouter_sdk,
api_key: System.get_env("OPENROUTER_API_KEY"),
default_headers: [
{"http-referer", "https://yourapp.com"},
{"x-title", "Your App"}
]start a finch pool somewhere in your supervision tree (or set
auto_start_finch: true to let the sdk start one):
children = [
{Finch, name: OpenrouterSdk.Finch}
# ...
]{:ok, response} =
OpenrouterSdk.chat(%{
model: "openai/gpt-4o-mini",
messages: [%{role: "user", content: "what's the capital of france?"}]
})
response["choices"] |> hd() |> get_in(["message", "content"]){:ok, stream} =
OpenrouterSdk.chat_stream(%{
model: "openai/gpt-4o-mini",
messages: [%{role: "user", content: "tell me a story"}]
})
stream
|> Stream.flat_map(fn
{_, %{"choices" => [%{"delta" => %{"content" => c}} | _]}} when is_binary(c) -> [c]
_ -> []
end)
|> Enum.each(&IO.write/1){:ok, ref} = OpenrouterSdk.chat_stream(payload, into: self())
receive do
{:openrouter_event, ^ref, event} -> handle(event)
{:openrouter_event, ^ref, :complete} -> :done
end{:ok, msg} =
OpenrouterSdk.messages(%{
model: "anthropic/claude-sonnet-4-6",
max_tokens: 1024,
messages: [%{role: "user", content: "hi"}]
})streaming yields {event_name, decoded_payload} tuples
("message_start", "content_block_delta", "message_stop", ...).
{:ok, %{"data" => vectors}} =
OpenrouterSdk.embeddings(%{
model: "openai/text-embedding-3-small",
input: ["the quick brown fox", "jumped over the lazy dog"]
})routes through /chat/completions with audio output. works against
any model in OpenrouterSdk.Catalog.Models.tts_models/0.
{:ok, mp3} =
OpenrouterSdk.speak(%{
model: "openai/gpt-audio-mini",
text: "hello there",
voice: "alloy",
format: "mp3"
})
File.write!("hello.mp3", mp3)routes through /chat/completions with an input_audio content
block. works against any model in
OpenrouterSdk.Catalog.Models.audio_input_models/0.
{:ok, text} =
OpenrouterSdk.transcribe(%{
audio: File.read!("recording.webm"),
mime: "audio/webm",
model: "google/gemini-2.5-flash"
})the dedicated
/audio/speechand/audio/transcriptionsendpoints (OpenrouterSdk.speech/2/transcription/2) are still shipped for completeness but have caveats — see the moduledocs.
end-user auth (each user brings their own openrouter account):
verifier = OpenrouterSdk.OAuth.generate_code_verifier()
challenge = OpenrouterSdk.OAuth.code_challenge(verifier)
# stash `verifier` somewhere keyed by the user's session, then redirect:
url =
OpenrouterSdk.OAuth.build_authorize_url(
"https://yourapp.com/openrouter/callback",
code_challenge: challenge,
code_challenge_method: :s256
)
# on the callback, after the user grants access:
{:ok, %{"key" => api_key}} =
OpenrouterSdk.OAuth.exchange_code(
conn.params["code"],
code_verifier: verifier
)
# pass the per-user key on every call:
OpenrouterSdk.chat(payload, api_key: api_key)no plug helpers, no token storage — you own the redirect route.
defmodule MyApp.Retry do
@behaviour OpenrouterSdk.Middleware
@impl true
def call(req, next, opts) do
max = Keyword.get(opts, :max, 3)
attempt(req, next, max)
end
defp attempt(req, next, 0), do: next.(req)
defp attempt(req, next, remaining) do
case next.(req) do
{:error, %{retryable?: true}} = _err ->
Process.sleep(:rand.uniform(200) * (4 - remaining))
attempt(req, next, remaining - 1)
result ->
result
end
end
endconfig :openrouter_sdk,
middleware: [
{MyApp.Retry, max: 3},
{MyApp.RotateOnExhaustion, models: ["openai/gpt-4o-mini", "anthropic/claude-haiku-4-5"]}
]middleware sees every request (buffered + the start of streams). per-chunk events flow directly to your stream consumer.
OpenrouterSdk.Catalog.Models.list/0 returns the embedded snapshot —
zero-io, refreshed by ci. use it to drive your own rotation logic:
OpenrouterSdk.Catalog.Models.list(modality: "text")
OpenrouterSdk.Catalog.Models.get("anthropic/claude-sonnet-4-6")
OpenrouterSdk.Catalog.Models.context_length("openai/gpt-4o-mini")if you want a live read instead, call OpenrouterSdk.models/0 (hits
/api/v1/models over the wire).
mix openrouter.snapshot # write fresh snapshot
mix openrouter.snapshot --check # verify against upstream (ci pr gate)a daily github actions workflow runs mix openrouter.snapshot and
opens a pr titled chore: refresh openrouter catalog whenever the
upstream catalog has drifted.
every public function returns {:ok, term} or {:error, %OpenrouterSdk.Error{}}.
%OpenrouterSdk.Error{
kind: :rate_limit, # :transport | :timeout | :auth | :rate_limit | :payment_required
# | :invalid_request | :server | :stream_disconnect | :decode
status: 429,
code: "rate_limited",
message: "...",
retryable?: true, # the signal middleware uses
body: ... # the raw decoded upstream body
}every request emits [:openrouter_sdk, :request, :start | :stop | :exception]
spans. attach with :telemetry.attach/4 for tracing.