Multi-provider LLM client for Almide. One interface, every provider.
# almide.toml
[dependencies]
almai = { git = "https://github.com/almide-ai/almai", tag = "v0.5.0" }import almai
effect fn main() -> Unit = {
let r = almai.call("anthropic/claude-sonnet-4-6", [
almai.system("You are helpful."),
almai.user("What is 2+2?"),
])!
println(r.content)
println("Tokens: " + int.to_string(r.usage.total_tokens))
}
| Prefix | Provider | Env vars |
|---|---|---|
anthropic/ |
Anthropic Messages API | ANTHROPIC_API_KEY |
openai/ |
OpenAI Chat Completions | OPENAI_API_KEY |
openrouter/ |
OpenRouter (100+ models) | OPENROUTER_API_KEY |
cf/ |
Cloudflare Workers AI | CF_ACCOUNT_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL |
azure/ |
Azure OpenAI | AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT |
google/ |
Google Gemini | GOOGLE_API_KEY |
cli/claude |
Claude Code CLI | (authenticated CLI) |
cli/codex |
Codex CLI | (authenticated CLI) |
// Anthropic
almai.call("anthropic/claude-sonnet-4-6", msgs)
// Cloudflare Workers AI (open models)
almai.call("cf/@cf/meta/llama-3.3-70b-instruct-fp8-fast", msgs)
// OpenRouter (any model)
almai.call("openrouter/meta-llama/llama-3.3-70b-instruct", msgs)
// Google Gemini
almai.call("google/gemini-2.5-pro", msgs)
let opts = almai.defaults()
|> almai.with_max_tokens(8192)
|> almai.with_temperature(0.7)
|> almai.with_system("You are a code reviewer.")
|> almai.with_stop(["END"])
let r = almai.call_with("openai/gpt-4o", msgs, opts)!
defaults() uses temperature: 0.0 — almai is built for measurement, and a
benchmark that resamples on every run measures the sampler as much as the
thing under test. Pass with_temperature for prose.
with_seed pins the sampler; 0 (the default) means "no seed", so a caller
who never asked for one never silently gets one.
let opts = almai.defaults() |> almai.with_seed(7)
let r = almai.call_with("cf/@cf/meta/llama-3.3-70b-instruct-fp8-fast", msgs, opts)!
A dropped option must not look like an honoured one. Providers differ in what they put on the wire, so every response reports what it actually sent:
r.sampling.seed_sent // Bool — was `seed` written into the request?
r.sampling.seed // Int — the value that went out (0 if unsent)
r.sampling.temperature_sent // ... same shape for temperature / top_p /
r.sampling.max_tokens_sent // max_tokens
A manifest can therefore print seed: 7 versus seed: null (not sent)
instead of quoting CallOptions and implying a pinned run that never was.
| Provider | max_tokens | temperature | top_p | seed |
|---|---|---|---|---|
cf/ |
✓ | ✓ | ✓ | ✓ |
openai/, openrouter/, groq/ |
✓ | ✓ | — | — |
anthropic/, azure/, google/, bedrock/ |
✓ | — | — | — |
cli/ |
— | — | — | — |
seed_sent: true means the field reached the API, not that the model
honoured it — no text-generation API reports back which sampling controls it
applied, and Cloudflare's GLM models document seed as "best effort". Whether
a given model respects a seed is established by running the same
(model, seed, temperature, task set) twice and comparing.
let opts = almai.defaults() |> almai.with_json_mode
let r = almai.call_with("openai/gpt-4o", [
almai.user("List 3 colors as JSON array"),
], opts)!
let parsed = almai.parse_content_as_json(r)!
import almai
import json
let weather_tool = Tool {
name: "get_weather",
description: "Get current weather for a city",
parameters: value.object([
("type", value.str("object")),
("properties", value.object([
("city", value.object([
("type", value.str("string")),
("description", value.str("City name")),
])),
])),
("required", value.array([value.str("city")])),
]),
}
let opts = almai.defaults() |> almai.with_tools([weather_tool])
let r = almai.call_with("anthropic/claude-sonnet-4-6", [
almai.user("What's the weather in Tokyo?"),
], opts)!
if almai.has_tool_calls(r) then {
let tc = almai.first_tool_call(r) |> option.unwrap_or(ToolCall { id: "", name: "", arguments: "" })
println("Tool: " + tc.name)
println("Args: " + tc.arguments)
} else {
println(r.content)
}
call_with blocks until the answer is in. An agent needs more: to show the answer as
it is written, to stop it when the person presses Esc, to give up on a stream that has
gone quiet, and to start a second copy of a request that is not arriving. almai.live
does that for every OpenAI-compatible service, Anthropic, Gemini and Claude Code's
claude -p, and
golemide and comide
are built on it.
import almai.live as live
let req = live.Request { model: "cf:glm-5.3", messages: msgs, tools: tools, effort: "low" }
let p = live.start(req, live.limits())! // 900 s in all, 120 s of silence
while not live.is_done(p)! {
show(live.text_so_far(p)!) // what has arrived so far
if esc_pressed() then live.cancel(p)! else env.sleep_ms(250)
}
let r = live.finish(p)! // content, reasoning, calls, finish, usage, cost, warnings
// Or all at once, with a second copy after 120 s and a 6-minute cap:
let ran = live.run(req, live.limits(), 120000, 360000)!
Model ids are PROVIDER/MODEL or PROVIDER:MODEL:
| Model | Runs on | Needs |
|---|---|---|
cf:glm-5.3, cf/glm-5.3-flash, cf/@cf/… |
Cloudflare Workers AI | CLOUDFLARE_ACCOUNT_ID (or CF_ACCOUNT_ID) and CLOUDFLARE_API_TOKEN (or CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY) |
openai:…, openrouter:…, deepseek:…, zai:…, groq:… |
that OpenAI-compatible service | OPENAI_API_KEY, … |
ollama:…, lmstudio:… |
a local server | nothing |
anthropic:… |
Anthropic's Messages API | ANTHROPIC_API_KEY (ANTHROPIC_BASE_URL optional) |
gemini:…, google:… |
Gemini's generateContent | GEMINI_API_KEY or GOOGLE_API_KEY (GEMINI_BASE_URL optional) |
NAME:MODEL |
any other OpenAI-compatible service | NAME_BASE_URL, NAME_API_KEY |
claude, claude:opus, cli/claude |
Claude Code's claude -p, on its own login |
claude on PATH |
- An HTTP request is a call handle of the runtime's (
http.start, Almide 0.64.0 or later), so it can be read while it arrives, stopped at any point, and bounded in time as a whole and between bytes.clauderuns in the background and writes to files. Chat requests are always streamed: a non-streamed Cloudflare request past about four minutes is ended with408. Credentials go in the request's headers, never on a command line or in a file. messagesandtoolsare in the OpenAI chat shape whatever the model; each format translates them. For Anthropic: the system on top, calls astool_use, a round of results in one user turn. For Gemini:systemInstruction,functionCall/functionResponsematched by name, the tools' JSON Schema as it is.schemaasks for an answer in that JSON Schema:response_format; a forced tool namedanswerfor Anthropic, whose input comes back as the content;responseJsonSchemafor Gemini;--json-schemafor claude.effortis sent only when set. OpenAI-compatible services getreasoning_effort: Cloudflare takes it for every model, other services refuse it on a model that does not reason. Anthropic and Gemini get a thinking budget (low1024,medium4096,high16384 tokens, or a number). Anthropic's is left out, with a warning, where Anthropic would refuse it: beside the forcedanswertool, and on a conversation with earlier tool calls, whose thinking blocks the OpenAI shape has nowhere to keep.- Cost is the provider's own meter when it reports one (Cloudflare's neurons,
OpenRouter's
usage.cost, claude's total), else Cloudflare's price table, else 0. clauderunsclaude -pwith its tools, settings, hooks and MCP servers off; your globalCLAUDE.mdand memory are still read (only--bareleaves them out, and it takes an API key). Withtools, the answer comes back as JSON in the reply's text, not through--json-schema: holding the schema as a tool, Claude tried to call the listed tools as its own and gave up. Withsessionset to a file, the session is continued with--resume, so each call sends only the new messages.- Failures are
almai.core.LlmError:ErrRateLimited(retry_after_ms, …),ErrOverloaded,ErrTimeout,ErrTransport,ErrContextTooLong,ErrContentFiltered,ErrAuth,ErrNotFound,ErrBadRequest,ErrCancelled,ErrTruncated, … (the cases carry their type's name because an Almide case name is visible program-wide, almide#2636, and a bareStopwould clobber a user's own type). Onecore.classifyreads a failed reply, body first (a context overflow comes as a 400);core.retryable(e)says whether to try again as is andcore.wants_fallback(e)whether to try another model; Retry-After is read from the response headers.core.error_text(e)words it as before:status NNN: …,transport: …,request timeout: …. finishisFinishStop | FinishLength | FinishToolCalls | FinishFiltered | FinishOther(raw), from one table of every provider's word, withraw_finishkept.usagesplits uncached input, cache read, cache write, output and reasoning; a count the provider did not report isnone, not 0.costsays where it came from:"provider","table"or"none".warningslists what the call could not honour (claude -p and a reasoning effort, say).- Hedging stays in
live.run's loop. Almide'sfan.anytakes the first Ok in source order and waits for the earlier arms, so it suits a fallback chain run in parallel (it stops the later arms once an earlier one wins), not a first-to-finish race.
import almai
import almai.conv
let conversation = conv.empty()
|> conv.add_system("You are a math tutor.")
|> conv.add_user("What is a derivative?")
|> conv.add_assistant("A derivative measures the rate of change...")
|> conv.add_user("Can you give an example?")
let r = almai.call("anthropic/claude-sonnet-4-6", conv.messages(conversation))!
println(r.content)
call_retry wraps the dispatch in an exponential-backoff loop. HTTP 429 / 5xx
and connection errors are retried; auth / malformed-request errors propagate
immediately. Initial delay 1000 ms, doubles each retry.
import almai
let r = almai.call_retry(
"anthropic/claude-sonnet-4-6",
[almai.user("Hello")],
almai.defaults(),
3, // max_attempts: 1 try + 2 retries
)!
println(r.content)
For custom initial delay, use call_retry_with_delay(..., max_attempts, base_delay_ms).
src/
mod.almd Public API, types, dispatch
live.almd Calls you can watch, stop and bound in time (HTTP handle / claude -p)
answer.almd What a finished HTTP request amounts to: the status, then the stream
core.almd Finish, usage, cost and errors, shared by every provider
wire.almd What the wire formats share: the request asked, the answer read
wire_openai.almd Chat completions: body and stream, pure (OpenAI, Cloudflare, …)
wire_anthropic.almd Anthropic Messages: body and stream, pure
wire_gemini.almd Gemini generateContent: body and stream, pure
wire_claude.almd claude -p: prompt, command line and reply, pure
tools.almd Tool calling types and JSON Schema helpers
conv.almd Conversation builder
providers/
openai.almd OpenAI / OpenRouter (OpenAI-compatible)
anthropic.almd Anthropic Messages API
cloudflare.almd Cloudflare Workers AI
azure.almd Azure OpenAI
google.almd Google Gemini
cli.almd Claude Code / Codex CLI
All providers are pure Almide — no external SDK dependencies. The providers under
providers/ call the REST API via http.request; almai.live holds an HTTP call handle or runs
claude in the background, and reads what comes back with the wire_* modules. Those hold no
transport at all: encode makes the body, fold reads a stream or the part of one
that has arrived, and a test gives them a recorded stream.
MIT