openrouter_sdk

elixir sdk for openrouter. thin, finch-based, ships zero policy.

supports:

retries, exponential backoff, model rotation, and circuit breakers are intentionally not in this package. compose them yourself via the OpenrouterSdk.Middleware behaviour.

install

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

quick examples

chat (buffered)

{: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"])

chat (streaming)

{: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)

chat (streaming via pid — useful for liveview)

{:ok, ref} = OpenrouterSdk.chat_stream(payload, into: self())

receive do
  {:openrouter_event, ^ref, event} -> handle(event)
  {:openrouter_event, ^ref, :complete} -> :done
end

anthropic messages

{: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", ...).

embeddings

{:ok, %{"data" => vectors}} =
  OpenrouterSdk.embeddings(%{
    model: "openai/text-embedding-3-small",
    input: ["the quick brown fox", "jumped over the lazy dog"]
  })

speech (tts)

{:ok, mp3} =
  OpenrouterSdk.speech(%{
    model: "openai/tts-1",
    input: "hello there",
    voice: "alloy",
    response_format: "mp3"
  })

File.write!("hello.mp3", mp3)

transcription (stt)

{:ok, %{"text" => text}} =
  OpenrouterSdk.transcription(%{
    file: "recording.wav",
    model: "openai/whisper-1",
    language: "en"
  })

oauth pkce

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.

custom middleware (retry / rotation / backoff)

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
end
config :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.

models / providers catalog

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

refreshing the snapshot manually

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.

errors

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
}

telemetry

every request emits [:openrouter_sdk, :request, :start | :stop | :exception] spans. attach with :telemetry.attach/4 for tracing.