lemon_ai
A provider-agnostic LLM client for Elixir. Call 27 model providers — Anthropic,
OpenAI, Google, AWS Bedrock, Azure, Groq, Mistral, xAI, DeepSeek, OpenRouter, and more —
through one streaming API, with a built-in model registry, per-provider circuit
breaking and rate limiting, and cost accounting. No web framework, no umbrella, no
runtime dependencies beyond req,
jason, and
nimble_options.
The whole surface is LemonAi.stream/3 and LemonAi.complete/3 plus a handful of helpers. Point
a %LemonAi.Types.Model{} at any provider and the rest of your code stays identical.
Install
def deps do
[
{:lemon_ai, "~> 0.2"}
]
end
0.2.0 is the first installable release line; the 0.1.0 release on Hex cannot be
installed and is retired. Until 0.2.0 is on Hex, depend on the repository instead:
{:lemon_ai, github: "z80dev/lemon", sparse: "apps/lemon_ai"}
The OTP application is named :lemon_ai, so you call it as LemonAi.*. It starts its own
supervision tree (circuit breakers, rate limiters, the model cache) automatically.
Quickstart
Set the API key for whichever provider you want (ANTHROPIC_API_KEY, OPENAI_API_KEY,
GOOGLE_GENERATIVE_AI_API_KEY, …) and make a blocking call:
model = LemonAi.Models.get_model(:anthropic, "claude-haiku-4-5")
context =
LemonAi.new_context(system_prompt: "You are a helpful assistant.")
|> LemonAi.Types.Context.add_user_message("Explain OTP supervision in two sentences.")
{:ok, message} = LemonAi.complete(model, context)
IO.puts(LemonAi.get_text(message))
Switch providers by changing one line — the context, options, and result handling are identical:
model = LemonAi.Models.get_model(:openai, "gpt-4o")
model = LemonAi.Models.get_model(:google, "gemini-2.5-pro")
model = LemonAi.Models.get_model(:groq, "llama-3.3-70b-versatile")
The API key resolves in this order: opts.api_key you pass in →
provider-specific env var (e.g. ANTHROPIC_API_KEY) → generic fallback. So you can pass
keys explicitly or rely on the environment.
Streaming
LemonAi.stream/3 returns an LemonAi.EventStream you consume as a lazy stream of events:
{:ok, stream} = LemonAi.stream(model, context, %{temperature: 0.7, reasoning: :medium})
stream
|> LemonAi.EventStream.events()
|> Enum.each(fn
{:text_delta, _idx, delta, _partial} -> IO.write(delta)
{:thinking_delta, _idx, delta, _partial} -> IO.write([IO.ANSI.faint(), delta, IO.ANSI.reset()])
{:done, _reason, _message} -> IO.puts("\n-- done --")
{:error, _reason, message} -> IO.puts("Error: #{message.error_message}")
_ -> :ok
end)
Convenience helpers on the stream:
text = LemonAi.EventStream.collect_text(stream) # blocking, returns full text
{:ok, message} = LemonAi.EventStream.result(stream) # blocking, returns final message
%{queue_size: _, dropped: _} = LemonAi.EventStream.stats(stream)
LemonAi.EventStream.cancel(stream, :user_requested)
Non-blocking lifecycle observers can call
LemonAi.EventStream.subscribe_result/2; it synchronously registers the
subscriber and later sends {:event_stream_result, reference, result}. This
guarantees that a subsequent cancellation cannot overtake result registration.
LemonAi.complete/3 is just stream/3 + EventStream.result/1 collected for you.
Tool use
tools = [
%LemonAi.Types.Tool{
name: "get_weather",
description: "Get weather for a location",
parameters: %{
type: "object",
properties: %{location: %{type: "string"}},
required: ["location"]
}
}
]
context =
LemonAi.new_context(system_prompt: "You can check weather.", tools: tools)
|> LemonAi.Types.Context.add_user_message("Weather in Paris?")
{:ok, message} = LemonAi.complete(model, context)
for tc <- LemonAi.get_tool_calls(message) do
result = %LemonAi.Types.ToolResultMessage{
tool_call_id: tc.id,
tool_name: tc.name,
content: [%LemonAi.Types.TextContent{text: "Sunny, 22C"}],
is_error: false
}
context =
context
|> LemonAi.Types.Context.add_assistant_message(message)
|> LemonAi.Types.Context.add_tool_result(result)
{:ok, final} = LemonAi.complete(model, context)
IO.puts(LemonAi.get_text(final))
end
Provider capability matrix
Every provider is reached through one of 10 wire-protocol modules. The 27 registered
provider IDs in LemonAi.Models route through these — for example Groq, xAI, DeepSeek,
Qwen, Cerebras, OpenRouter, Vercel AI Gateway, HuggingFace, Fireworks, and Mistral all
speak the OpenAI Chat Completions format, so they inherit its capabilities.
The migration replaced 25 provider-module literal catalogs with 26 JSON resources under
priv/models/, loaded at compile time by LemonAi.Models.Catalog. The counts differ
because Google uses google.json plus google_antigravity_extras.json. The
:google_antigravity registry is derived from entries tagged for that provider plus the
extras file; :"openai-codex" uses an explicit ChatGPT OAuth catalog rather than
exposing API-key-only models under the subscription endpoint. The files are compiler
external resources, so edits recompile their provider modules. Invalid JSON and malformed entries
fail compilation with catalog and model-key context, including invalid field types,
negative sizes, negative costs, and unsupported compatibility overrides. Atom-valued API,
provider, input, and compatibility keys use explicit allowlists rather than creating atoms
from arbitrary catalog strings.
Wire module (api_id) |
Streaming | Tool calls | Vision (image input) | Reasoning / thinking | Cost data |
|---|---|---|---|---|---|
Anthropic :anthropic_messages |
✅ | ✅ | ✅ | ✅ | ✅ |
OpenAI Chat Completions :openai_completions |
✅ | ✅ | ✅ | ✅ | ✅ |
OpenAI Responses :openai_responses |
✅ | ✅ | ✅ | ✅ | ✅ |
OpenAI Codex :openai_codex_responses |
✅ | ✅ | ✅ | ✅ | ✅ |
Azure OpenAI :azure_openai_responses |
✅ | ✅ | ✅ | ✅ | ✅ |
Google Generative AI :google_generative_ai |
✅ | ✅ | ✅ | ✅ | ✅ |
Google Vertex :google_vertex |
✅ | ✅ | ✅ | ✅ | ✅ |
Google Gemini CLI :google_gemini_cli |
✅ | ✅ | ✅ | ✅ | ✅ |
AWS Bedrock :bedrock_converse_stream |
✅ | ✅ | ✅ | ✅ | ✅ |
Mistral Conversations :mistral_conversations |
✅ | — | — | — | ✅ |
Vision and reasoning are additionally gated per model by the model's input and
reasoning fields — a wire module supporting vision doesn't make a text-only model
accept images. The thinking request shape is driven by explicit capability flags on
each catalog entry: adaptive_thinking (Anthropic thinking: adaptive plus
output_config.effort, required from Claude Opus 4.7 on) and reasoning_efforts (the
Lemon levels a model accepts; other levels clamp to the nearest declared one via
LemonAi.Models.reasoning_effort_for/2). Query the specific model:
LemonAi.Models.supports_vision?(model)
LemonAi.Models.supports_reasoning?(model)
LemonAi.Models.supports_xhigh(model)
Codex and Anthropic request controls
Codex accepts a ChatGPT access token through api_key, including Lemon's configured
API-key authentication mode. The account header is derived from the URL-safe JWT
claims; an OpenAI API key is not a ChatGPT token. Reuse session_id across turns
and resumes: it supplies the prompt-cache key and matching session_id,
conversation_id and x-client-request-id affinity headers. IDs longer than
64 wire bytes become deterministic SHA-256-derived keys; IDs are never truncated
or replaced with a per-request random identity. Native coding sessions supply their
persisted session ID automatically. Provider cache hits appear in usage.cache_read;
hits depend on the provider's prefix length, retention and routing, not merely on
sending a key. Sampling temperature and output-token caps are deliberately omitted
because the Codex endpoint rejects them.
Codex sends the same pinned version: 0.153.0 used for live model discovery;
caller headers cannot accidentally override that capability version. Function
tools explicitly use strict: false so optional parameters remain optional rather
than being auto-filled by the endpoint.
For deliberate local diagnostics, StreamOptions.on_payload observes the final
request body and on_usage observes raw terminal usage, including vendor
attribution.request_fields. Both receive only provider/model/session metadata,
have ignored return values, and propagate callback failures as stream errors.
Bodies can contain private content: retain only counts/hashes, never raw payloads
or authentication data. These hooks are disabled by default.
Lemon onboarding can explicitly import an existing Codex CLI ChatGPT login with
lemon setup --provider openai-codex --auth codex-cli --non-interactive.
This copies refreshable OAuth credentials into Lemon's encrypted secret store;
it neither refreshes during import nor writes the Codex login file. Import is a
snapshot, not continuous synchronization or a separate browser login. Subsequent
refreshes persist only Lemon's secret, and persistence failure is surfaced rather
than reporting success with lost rotating credentials.
For Responses and Codex, thinking_budgets: %{summary: "auto", service_tier: "priority"}
selects reasoning summaries and a service tier ("auto", "default", "flex" or
"priority" where the model supports it). Codex honors tool_choice. Returned
service tiers take precedence for billing; Codex tier multipliers come from model
metadata (for example GPT-5.5 priority is 2.5×). Reasoning output tokens are already
included in the provider's output count, not charged twice.
Native Anthropic requests support provider_options: %{anthropic: %{cache_retention: :none | :short | :long}}.
Short-lived caching is the default; :long requests the one-hour TTL and :none
removes cache markers. Adaptive thinking uses catalog capabilities, and catalog
context limits include current 1M-context Claude models without adding an obsolete
context beta. Provider conversion preserves full history; preflight compaction is
the caller's responsibility rather than silent wire truncation.
The 27 registered provider IDs: :anthropic, :openai, :"openai-codex",
:amazon_bedrock, :google, :google_antigravity, :kimi, :kimi_coding,
:opencode, :opencode_go, :xai, :mistral, :cerebras, :deepseek, :qwen,
:minimax, :zai, :azure_openai_responses, :github_copilot, :google_gemini_cli,
:google_vertex, :groq, :huggingface, :minimax_cn, :fireworks, :openrouter,
:vercel_ai_gateway.
What you get beyond raw HTTP
Circuit breaking
Each provider gets its own circuit breaker (closed → open → half-open), lazily started the first time you call it. After a run of failures the breaker opens and fails fast instead of hammering a down provider.
config :lemon_ai, :circuit_breaker,
failure_threshold: 5, # failures before opening (default: 5)
recovery_timeout: 30_000 # ms before half-open recovery (default: 30_000)
LemonAi.CircuitBreaker.open?(:anthropic)
LemonAi.CircuitBreaker.reset(:anthropic)
LemonAi.CircuitBreaker.get_state(:anthropic) # state, failure count, last failure reason
Rate limiting and concurrency caps
A per-provider token bucket plus an in-flight concurrency cap, both enforced by the
dispatcher before the provider is ever called. A caller that finds the provider at its
cap waits in a FIFO queue for a free slot (default up to 120s,
config :lemon_ai, LemonAi.CallDispatcher, queue_timeout_ms: ...) before
{:error, :max_concurrency} is returned.
config :lemon_ai, :rate_limiter,
tokens_per_second: 10, # refill rate (default: 10)
max_tokens: 20 # bucket capacity (default: 20)
LemonAi.CallDispatcher.set_concurrency_cap(:anthropic, 20)
LemonAi.CallDispatcher.get_active_requests(:anthropic)
Cost tracking
Every model carries pricing, so you can price any response:
{:ok, message} = LemonAi.complete(model, context)
cost = LemonAi.calculate_cost(model, message.usage)
# cost.total, cost.input, cost.output, cost.cache_read, cost.cache_write (US dollars)
Input usage excludes cache-read/write tokens; total tokens include them. Hourly
Anthropic writes are reported as usage.cache_write_long, a subset of cache_write.
The canonical cost calculation includes their higher write price and uses the
catalog's long-context rate when all input tokens (including cache) cross its
threshold. Prefer the returned message.usage.cost when a service tier was used,
since recalculating base model prices alone does not retain the response's tier.
Automatic retries
Providers retry transient failures (429, 529 overloaded, 5xx, connection resets, TLS
hiccups) on the call's own async task, so one slow retry never blocks another caller.
Anthropic, OpenAI Chat Completions, Responses and Codex share one policy: 4 attempts,
exponential backoff with jitter starting around 1s and capped at 30s, and the server's
retry-after / retry-after-ms / Anthropic anthropic-ratelimit-*-reset hint when
present. A server-requested wait longer than max_retry_after_ms (60s) is not slept
in-provider; the error message carries it as AssistantMessage.retry_after_ms so the
caller can schedule its own retry.
config :lemon_ai, :provider_retry,
max_attempts: 4, base_delay_ms: 1_000, max_delay_ms: 30_000, max_retry_after_ms: 60_000
Authentication failures, invalid requests, context overflow and exhausted billing
or subscription quotas are not retried in place. Quota exhaustion remains
:rate_limit for credential rotation, but LemonAi.Error.retryable?/1 returns
false; temporary throttling returns true. classify/1 and retryable?/1 accept
HTTP tuples, parsed error maps, transport failures and terminal assistant messages.
Responses streams that disconnect or time out before a terminal event are errors,
not successful empty/truncated replies. Both CRLF and multiline SSE data are parsed.
Terminal AssistantMessage.error_details carries the canonical classification,
retry eligibility, HTTP status and server retry hints; error_message is only
display text. Call Error.retryable?(message) on the whole terminal message.
Req, Finch and Mint closed/reset/timeout failures retain typed reasons through
Responses parsing and are transient even when their display text is opaque.
Structured metadata survives JSON session persistence without creating atoms
from external category names.
Generic resource exhaustion/throttling remains retryable under the bounded policy;
only explicit account, credit, billing or depleted-quota evidence makes it permanent.
Legacy provider wrappers using status 0 for closed connections classify as
transport failures, not malformed HTTP requests.
Token estimation
LemonAi.Tokens gives fast token estimates for budgeting and thresholds:
LemonAi.Tokens.estimate_chars("some prompt text") # ~ String.length / 4
LemonAi.Tokens.estimate_bytes(payload) # ~ byte_size / 4
Caveat: this is a rough 4-characters-per-token heuristic, not a real tokenizer. It will diverge from any model's actual token count and must not be trusted for billing or hard context-window limits. Use it for quick thresholds and diagnostics only; use
message.usage(the provider's reported counts) for anything that needs to be accurate.
Actionable, classified errors
LemonAi.Error parses provider error bodies into a normalized category and tells you whether
to retry:
parsed = LemonAi.Error.parse_http_error(429, response_body, headers)
# parsed.category => :rate_limit | :auth | :client | :server | :transient
# parsed.retryable => true
# parsed.rate_limit_info.retry_after => merged from headers or body hints
LemonAi.Error.retryable?(:timeout) # => true
LemonAi.Error.auth_error?({:http_error, 401, "Unauthorized"}) # => true
LemonAi.Error.suggested_retry_delay({:http_error, 429, _}) # => 60_000
It handles the OpenAI/Anthropic map shapes, Google errors arrays, FastAPI/Pydantic
detail arrays, OAuth error_description, and JSON:API errors — so you get a useful
message instead of a raw blob, whatever provider you hit.
Model lookup
LemonAi.Models.get_model(:anthropic, "claude-haiku-4-5") # provider + id
LemonAi.Models.find_by_id("gpt-4o") # search all providers by id
LemonAi.Models.get_models(:openai) # all models for a provider
LemonAi.Models.get_providers() # all known provider atoms
The bundled current families include GPT-5.5 and GPT-5.6 API models, ChatGPT Codex
GPT-5.5 / GPT-5.6 Luna, Sol and Terra / GPT-6 Astra, Claude Opus 4.8 and Claude 5
families, and Gemini 3.1 Pro through Gemini 3.8 Flash. Limits and per-million-token
prices track the local omp catalog; unsupported realtime/audio/image-generation
protocols are not advertised through the text coding APIs.
Existing integration exclusions remain in effect even when the reference catalog
still lists an old preview: Vertex's gemini-3-flash-preview, gemini-3-pro-preview
and gemini-3.1-pro-preview are not advertised by Lemon.
Lemon's runtime model picker uses account-scoped Codex discovery, not every
bundled Codex SKU. The ChatGPT /codex/models?client_version=0.153.0 result is
cached for five minutes by account and endpoint; tokens, account identifiers and
login files are not stored in that cache. Refreshing the picker may explicitly
bypass the cache (models.list with forceRefresh: true). A discovery failure
does not advertise unverified Codex models. Known hidden models remain usable
when the account advertises support; visibility is not an authorization flag.
LemonAi.Models.list_models(
discover_codex: true,
codex_access_token: concrete_chatgpt_token
)
Runtime role selectors (@smol, @slow, custom roles) are checked against the
same account cache once a concrete ChatGPT credential is available. Unsupported
roles fail before submitting a model request and identify the role, rejected
model and supported alternatives; change [model_roles] or choose a model from
the picker. Static registry lookups remain network-free for metadata and tests.
You can also skip the registry entirely and hand-build a %LemonAi.Types.Model{} — the
registry is a convenience, not a requirement.
Configuration reference
config :lemon_ai, LemonAi.CallDispatcher,
stream_result_timeout_ms: 300_000 # how long the dispatcher tracks a stream's result
LemonAi.ModelCache caches provider GET /models availability with a configurable TTL
(default 5 minutes).
Key types
All defined in LemonAi.Types:
%LemonAi.Types.Model{
id: String.t(),
name: String.t(),
api: atom(), # must match a registered api_id
provider: atom(), # keyed for circuit breaker / rate limiter
base_url: String.t(),
reasoning: boolean(),
adaptive_thinking: boolean(), # Anthropic adaptive thinking + effort
reasoning_efforts: [:minimal | :low | :medium | :high | :xhigh], # [] = undeclared
input: [:text | :image],
cost: %LemonAi.Types.ModelCost{input: float(), output: float()},
context_window: non_neg_integer(),
max_tokens: non_neg_integer(),
headers: map(),
compat: map() | nil
}
%LemonAi.Types.StreamOptions{
temperature: float() | nil,
max_tokens: non_neg_integer() | nil,
api_key: String.t() | nil,
headers: map(),
reasoning: :minimal | :low | :medium | :high | :xhigh | nil,
stream_timeout: timeout(), # default 300_000ms
tool_choice: atom() | String.t() | nil
# ...plus Vertex/OAuth fields: project, location, access_token, service_account_json
}
%LemonAi.Types.Context{
system_prompt: String.t() | nil,
messages: [message()], # stored newest-first for O(1) append
tools: [Tool.t()]
}
Context.messagesis stored reversed (newest first). UseLemonAi.Types.Context.get_messages_chronological/1when passing to an API directly.
Streaming event types
Events emitted by LemonAi.EventStream:
{:start, message}
{:text_start, idx, message}
{:text_delta, idx, delta, message}
{:text_end, idx, text, message}
{:thinking_start | :thinking_delta | :thinking_end, idx, ..., message}
{:tool_call_start, idx, message}
{:tool_call_delta, idx, json_fragment, message}
{:tool_call_end, idx, tool_call, message}
{:done, stop_reason, message}
{:error, stop_reason, message}
{:canceled, reason}
stop_reason is one of :stop | :length | :tool_use | :error | :aborted.
Architecture
Every call flows through a dispatcher that checks the circuit breaker, acquires a
rate-limit permit, and enforces the concurrency cap before invoking the provider.
Streaming responses come back through an LemonAi.EventStream GenServer with a bounded
queue, backpressure, owner monitoring, and timeouts.
LemonAi.stream/3 or LemonAi.complete/3
→ LemonAi.ProviderRegistry -- O(1) :persistent_term lookup by api_id
→ LemonAi.CallDispatcher.dispatch/2 -- circuit breaker + rate limiter + concurrency cap
→ LemonAi.Provider.stream/3 -- provider behaviour callback
→ LemonAi.EventStream -- async delivery, backpressure, cancellation
Supervision tree:
LemonAi.Supervisor (one_for_one)
├── Task.Supervisor (LemonAi.StreamTaskSupervisor)
├── Registry (LemonAi.RateLimiterRegistry)
├── Registry (LemonAi.CircuitBreakerRegistry)
├── LemonAi.ProviderSupervisor -- DynamicSupervisor for per-provider breakers/limiters
├── LemonAi.CallDispatcher
└── LemonAi.ModelCache
LemonAi.ProviderRegistry lives outside the tree in :persistent_term, so provider
mappings survive process restarts.
Adding a provider
Implement the LemonAi.Provider behaviour (stream/3, provider_id/0, api_id/0, and
optionally get_env_api_key/0), add a catalog file under priv/models/ with a
LemonAi.Models.* module that loads it, add any new API/provider value to the
explicit allowlist in LemonAi.Models.Catalog, and register the module in
LemonAi.Application:
LemonAi.ProviderRegistry.register(:my_provider_api, LemonAi.Providers.MyProvider)
Inside stream/3 you start an LemonAi.EventStream, run the HTTP request in a supervised
task, and push events (LemonAi.EventStream.push_async/2) until you complete or error the
stream. See any module under lib/ai/providers/ for the pattern.
Authentication and OAuth
Most providers authenticate with a bearer key from options or the environment. Providers
that require OAuth (GitHub Copilot, Google Gemini CLI, OpenAI Codex, Google Antigravity)
have helpers under LemonAi.Auth.* for the device-code / PKCE flows and token refresh. These
are protocol helpers only — they do not read or write any external app's secret store.
Common environment variables (used as a standalone fallback when a key isn't passed in options):
| Variable | Provider |
|---|---|
ANTHROPIC_API_KEY |
Anthropic (and Kimi/OpenCode/MiniMax compat) |
OPENAI_API_KEY |
OpenAI family |
GOOGLE_GENERATIVE_AI_API_KEY |
Google AI Studio (also GOOGLE_API_KEY, GEMINI_API_KEY) |
AZURE_OPENAI_API_KEY |
Azure OpenAI |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION |
Bedrock |
Debug/diagnostic toggles: LEMON_AI_HTTP_TRACE=1 (HTTP trace logging),
LEMON_AI_PROMPT_DIAGNOSTICS=1 (prompt size/token diagnostics), LEMON_AI_DEBUG=1
(raw Anthropic SSE logging).
Testing
mix test apps/lemon_ai # from umbrella root
mix test apps/lemon_ai/test/lemon_ai/circuit_breaker_test.exs # one file
mix test apps/lemon_ai/test/integration --include integration # needs API keys
HTTP is mocked with Req.Test stubs; see test/providers/ for patterns.
Dependencies
| Dependency | Purpose |
|---|---|
req ~> 0.5 |
HTTP client with streaming support |
jason ~> 1.4 |
JSON encoding/decoding |
nimble_options ~> 1.1 |
Options validation |
plug ~> 1.16 (test only) |
Req.Test stubs |
Used by
lemon_ai is the LLM layer of the Lemon agent
platform, where it drives long-running agents across every provider above. It has zero
dependency on the rest of that platform and is designed to be used entirely on its own.
License
MIT.