Install Airmet with a coding agent
For the agent reading this. Your human asked you to install Airmet. This page is your runbook. Follow it in order, ask your human at every step marked Ask, and finish with the "Done when" report at the end. It describes release 0.11.2. The same page as plain markdown, for fetching:
https://airmet.ai/install-for-agents.md.
1. What Airmet is
Airmet is one static binary, airmet, that runs next to your human's app as a
local OpenAI-compatible endpoint. It sends a share of the requests (α, 30% by
default) to two model providers at once, streams whichever answers first, and
lets the slower one keep running so its prompt cache stays warm (only the
runaway guard stops one that loops or runs far past the winner). The app
changes one setting: its base URL.
Prompts, completions and provider keys go only to the providers your human configures. A raced request is billed by both providers, so upstream requests run at about 1 + α of what they were.
The other requests go to the first leg alone. From release 0.11.0 such a
request is not lost when that leg fails before it sends a token: it is sent
once to the second leg, and the response says so. The exception is a request
the sidecar relays unraced, which has an X-Airmet-Bypass header (section 6).
A first leg whose key is refused is taken out of the pool. Section 6 has the
details and check 8 in section 9 shows it working.
2. Rules for you, the agent
- Never print, log or commit a key. Keys live in environment variables.
hedger.yamlnames the variable (api_key_env), never the value. Do notcatan env file, runenvorprintenvwithout a name, or type a key into a command line. Check that a variable is set without showing it:[ -n "$(printenv NAME)" ] && echo set || echo missing. - Never change providers, models, keys, α or the policy without asking.
Stay on
policy: twinleg: it does not cancel the slower leg when the winner arrives, which is why it races on any pair of keys with the defaults. - Telemetry is your human's decision. Do not start
airmet upbefore they have chosen (section 3) from what the installed binary says it sends (airmet traffic), and never add or drop--no-telemetry,--telemetryorAIRMET_TELEMETRY_SCHEMAon your own. - Your human signs in, not you.
airmet loginis optional. If they want it, you relay the link and the code it prints, and they approve it in their own browser. - Listen on
127.0.0.1only, unless your human says the app runs on another host or in a container. Then require a token (section 7). - No system changes without asking: no
sudo, no system packages, nopip --break-system-packages, no change to macOS quarantine or firewall settings. - Change only the app setting your human pointed you to (the base URL), and show them the diff.
- Verify every download against
SHA256SUMSbefore you run it. If a check fails, delete the file and stop. - When a step fails twice, stop and ask. Report the exact error lines, with any key redacted.
3. Ask your human first
Ask these before you install anything. Defaults are in brackets.
Before I install Airmet I need a few decisions (defaults in brackets):
1. Setup: (a) one marketplace key, each leg pinned to a different provider,
(b) two provider keys, one leg each, or (c) two OpenAI-compatible servers
you run (vLLM, SGLang, ...)?
2. For each of the two legs: the OpenAI-compatible base URL, the model id there
(the same model on both legs), and the NAME of the environment variable that
holds its key. Not the key itself. For (a), also the two provider slugs to pin.
3. Which leg is the more reliable (the higher rate limit, the fewer errors)?
It goes first: it serves every request that does not race. A request that
fails there before its first token is sent once to the other leg, at the
cost of a second upstream request. (A request the sidecar cannot race, for
example one with a field it does not know, is relayed to the first leg only
and is not retried.) [the first one you named]
4. Alpha, the share of requests that race both legs [0.3]. A raced request is
billed by both providers, so upstream requests run at about 1 + alpha.
With alpha above 0, the requests that do not race are still sent to the
second leg when the first fails (the exception is in question 3), so alpha
is a speed and cost setting, not a reliability one.
5. Telemetry is on by default (summary below). Keep it on, opt out, or send the
older aggregate format instead?
6. Where does your app set its base URL (a file, an env var, a config key)?
Does the app run on this machine, in a container, or on another host?
7. If one leg goes down: keep serving from the healthy leg [yes], or answer 503?
8. Install channel: bare binary [default], pip, npm or Docker?
9. May I send about 40 short test requests (16 tokens each) through your
keys to verify it?
10. Link this machine to your Airmet org with `airmet login`? [no] You would
approve it in your browser. It turns on the live route feed.
Read your human this telemetry summary as written, then record their choice:
Telemetry is ON by default. From release 0.11.0 it sends one record per
request it serves (telemetry v2), pushed gzipped every 30 seconds. The
first `airmet up` prints a notice listing all of it (shown once, and once more
if what is sent changes). A record describes a request, never its content:
- timing: arrival, time to first token and to the end, each leg's times
- outcome: the HTTP status and an error class (never an error's message
or body), which leg won, whether a rescue answered
- usage: each leg's token counts and the cost its provider reported
- route: how each leg is reached, its provider, model and region (a host
airmet does not know, or a model id naming your account, reports as
"custom")
- session: a random conversation id, the turn's number, the turn it continues
- shape: request class, message counts and byte sizes
- decision: the policy, how many legs it launched, why one was declined
- labels as configured: pool, scenario, surface, and each leg's id exactly
as named in hedger.yaml
Every push also carries a random install id (am_...) made on this machine at
first run and kept across restarts.
It never sends prompts, completions, API keys, error bodies, URLs or hostnames,
generation ids, or any hash of your text.
Options:
keep it on nothing to add
opt out --no-telemetry on the first start; saved for later runs
aggregate format AIRMET_TELEMETRY_SCHEMA=v1 on every start (the older
10-second aggregate windows instead of per-request records)
Racing works the same with telemetry off. Opting out is immediate and stops
every push; it does not delete the install id, which stays in the local config
file. If this machine is linked (airmet login), it sends its token and install
id whenever it registers or polls the route feed, and the public catalog routes
its legs use when it polls, even with telemetry off.
The full inventory of what leaves the machine is
traffic transparency. The binary also describes
itself: once it is installed (section 5), run airmet traffic, which makes no
network calls, and show your human its payload: paragraph before the first
start. That is this build's own list of what it sends. Where it differs from
the summary above, the binary's list is the one your human decides on.
4. Detect the platform
Run these and keep the two values for the rest of this page:
uname -s; uname -m
case "$(uname -s)/$(uname -m)" in
Linux/x86_64) PLAT=linux-amd64; NPMPLAT=linux-x64 ;;
Linux/aarch64|Linux/arm64) PLAT=linux-arm64; NPMPLAT=linux-arm64 ;;
Darwin/arm64) PLAT=darwin-arm64; NPMPLAT=darwin-arm64 ;;
Darwin/x86_64) PLAT=darwin-amd64; NPMPLAT=darwin-x64 ;;
*) PLAT=unsupported ;;
esac
# A terminal under Rosetta reports x86_64 on Apple silicon:
if [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = 1 ]; then PLAT=darwin-arm64; NPMPLAT=darwin-arm64; fi
echo "PLAT=$PLAT NPMPLAT=$NPMPLAT"
uname -s / uname -m |
PLAT (binary) |
NPMPLAT (npm) |
|---|---|---|
Linux / x86_64 |
linux-amd64 |
linux-x64 |
Linux / aarch64 |
linux-arm64 |
linux-arm64 |
Darwin / arm64 |
darwin-arm64 |
darwin-arm64 |
Darwin / x86_64 |
darwin-amd64 |
darwin-x64 |
Windows has a bare binary (airmet-windows-amd64.exe); this runbook covers
macOS and Linux, and WSL counts as Linux. Anything else: stop and tell your
human.
Many agent shells start fresh for every command, so PLAT and NPMPLAT do
not carry over: set them again (PLAT=… with the value printed above) at the
top of each command that uses them. The install blocks stop with an error when
one is unset.
Also check what is available:
command -v curl; command -v sha256sum || command -v shasum
command -v python3 && python3 -c 'import sys; print(sys.version_info >= (3, 9))'
command -v node && node --version # npm needs Node 18 or later
command -v docker
ldd --version 2>&1 | head -1 # "musl" (Alpine): use the bare binary
If command -v curl prints nothing (a stock python:3.12-slim image has
none), stop and ask your human to install curl, or to let you: every
install block in section 5 and most checks in section 9 use it, and a system
package is theirs to allow (rule 6). On Debian and Ubuntu the packages are
curl and ca-certificates.
Pick the channel your human chose. With no preference, use the bare binary: one file, one checksum, nothing else to install.
5. Install, with checksum verification
Work in a directory of its own, so the run records (airmet-results/) never
land in the app's repository. Every channel below leaves the command at
$HOME/airmet/bin/airmet, and every later block starts with
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH", so a shell that
starts fresh for each command still finds it:
mkdir -p "$HOME/airmet" && cd "$HOME/airmet"
Every file comes from https://airmet.ai/dl/v0.11.2/, and its SHA256SUMS lists every
artifact of the release. Each block below defines sha256check (sha256sum on
Linux, shasum -a 256 on macOS) and installs or runs nothing unless every
check prints <file>: OK (a file listed twice prints it twice). On anything
else (FAILED, no properly formatted … lines) the block deletes the
download and prints CHECKSUM FAILED: stop there and tell your human.
Bare binary (the default)
cd "$HOME/airmet"
: "${PLAT:?set PLAT to the value section 4 printed}"
sha256check() { if command -v sha256sum >/dev/null 2>&1; then sha256sum -c -; else shasum -a 256 -c -; fi; }
curl -fsSLO "https://airmet.ai/dl/v0.11.2/airmet-$PLAT"
curl -fsSLO "https://airmet.ai/dl/v0.11.2/SHA256SUMS"
if grep " airmet-$PLAT\$" SHA256SUMS | sha256check; then # airmet-<platform>: OK
mkdir -p bin && mv "airmet-$PLAT" bin/airmet && chmod +x bin/airmet
export PATH="$HOME/airmet/bin:$PATH"
airmet version
else
rm -f "airmet-$PLAT"; echo "CHECKSUM FAILED: nothing installed. Stop and tell your human."; false
fi
airmet version prints airmet v0.11.2 (go… <os>/<arch>).
pip (Python 3.9 or later; macOS, or Linux with glibc)
cd "$HOME/airmet"
sha256check() { if command -v sha256sum >/dev/null 2>&1; then sha256sum -c -; else shasum -a 256 -c -; fi; }
python3 -m venv .venv
.venv/bin/pip download --no-deps --only-binary=:all: \
--index-url https://airmet.ai/simple/ "airmet==0.11.2" -d wheels
curl -fsSLO "https://airmet.ai/dl/v0.11.2/SHA256SUMS"
ok=no
for w in wheels/airmet-0.11.2-*.whl; do
if grep " ${w#wheels/}\$" SHA256SUMS | (cd wheels && sha256check); then ok=yes; else ok=no; break; fi
done
if [ "$ok" = yes ]; then
.venv/bin/pip install --no-index --no-deps wheels/airmet-0.11.2-*.whl
mkdir -p bin && ln -sf "$HOME/airmet/.venv/bin/airmet" bin/airmet
export PATH="$HOME/airmet/bin:$PATH"
airmet version
else
rm -rf wheels; echo "CHECKSUM FAILED: nothing installed. Stop and tell your human."; false
fi
pip install --index-url https://airmet.ai/simple/ airmet is the one-line form: pip checks each file against the hash
the index lists. The download above adds the check against SHA256SUMS. The
wheel is the same binary plus a short Python shim with no dependencies.
npm (Node 18 or later)
cd "$HOME/airmet"
: "${NPMPLAT:?set NPMPLAT to the value section 4 printed}"
sha256check() { if command -v sha256sum >/dev/null 2>&1; then sha256sum -c -; else shasum -a 256 -c -; fi; }
# a package.json of its own: npm refuses to install "airmet" into a project named airmet
[ -f package.json ] || printf '{"name":"airmet-sidecar","private":true,"version":"0.0.0"}\n' > package.json
curl -fsSLO "https://airmet.ai/dl/v0.11.2/airmet-cli-$NPMPLAT-0.11.2.tgz"
curl -fsSLO "https://airmet.ai/dl/v0.11.2/airmet-0.11.2.tgz"
curl -fsSLO "https://airmet.ai/dl/v0.11.2/SHA256SUMS"
ok=yes
for f in "airmet-cli-$NPMPLAT-0.11.2.tgz" "airmet-0.11.2.tgz"; do grep " $f\$" SHA256SUMS | sha256check || ok=no; done
if [ "$ok" = yes ]; then
npm install --no-audit --no-fund "./airmet-cli-$NPMPLAT-0.11.2.tgz" "./airmet-0.11.2.tgz"
mkdir -p bin && ln -sf "$HOME/airmet/node_modules/@airmet/cli-$NPMPLAT/bin/airmet" bin/airmet
export PATH="$HOME/airmet/bin:$PATH"
airmet version
else
rm -f "airmet-cli-$NPMPLAT-0.11.2.tgz" "airmet-0.11.2.tgz"; echo "CHECKSUM FAILED: nothing installed. Stop and tell your human."; false
fi
bin/airmet links the platform package's binary itself, so every airmet
command on this page runs as written. Use that, not npx airmet, for the
long-running airmet up: npx waits in front of the binary as one more process
to supervise. Up to release 0.11.1, stopping npx on Linux left the binary
running; from release 0.11.2 the launcher stops it with its parent.
Docker (the image is linux/amd64)
On an arm64 host the image runs under emulation: prefer the bare binary there.
cd "$HOME/airmet"
sha256check() { if command -v sha256sum >/dev/null 2>&1; then sha256sum -c -; else shasum -a 256 -c -; fi; }
curl -fsSLO "https://airmet.ai/dl/v0.11.2/airmet-image-v0.11.2.tar.gz"
curl -fsSLO "https://airmet.ai/dl/v0.11.2/SHA256SUMS"
if grep " airmet-image-v0.11.2.tar.gz\$" SHA256SUMS | sha256check; then
docker load -i "airmet-image-v0.11.2.tar.gz" # Loaded image: airmet:v0.11.2
docker run --rm airmet:v0.11.2 version
else
rm -f "airmet-image-v0.11.2.tar.gz"; echo "CHECKSUM FAILED: nothing installed. Stop and tell your human."; false
fi
The whole archive is checked before docker load, so the image's
configuration and every layer are covered, not only the binary. If
SHA256SUMS has no line for the image archive, it cannot be verified: do not
load it, and use the bare binary instead.
6. Write hedger.yaml
Write $HOME/airmet/hedger.yaml from the template for your human's setup.
These are the tested pilot configs, with the provider details left for you to
fill in. Every template follows the same rules:
- Opaque leg ids:
leg0,leg1. Telemetry reports per-leg counts under eachidexactly as written, so an id named after a provider or a host would leave the machine as that name. - The more reliable leg first. At α = 0.3 about 70% of requests go to the first leg alone, so put the provider with the higher rate limit and the fewer errors there. When it fails, the retry in the next bullet costs a second upstream request and the time the failed attempt took.
- A request the first leg fails is sent to the second leg. From release
0.11.0, a request that runs on the first leg alone (α above 0) and fails
before that leg sends a token is sent once to the second leg: after a
connection error, a timeout, a 429, a 5xx, or a 401, 402, 403, 404 or 408
answer. Its response carries
hedge.rescuewith"kind":"solo_failed", andcost.upstream_requestsreads 2. A first leg that sends nothing at all, not even response headers, for half the first-token deadline (15 s of the default 30 s) is raced by the second leg ("kind":"solo_hung"), and the silent leg is cut if the second answers first. A request is never sent again once the first leg has sent a token (a leg that dies mid-answer still ends the stream), when the first leg refuses it as malformed (400, 413, 422), or when only one leg is healthy. It is on underpolicy: twinleg;hedge.rescue_solo: falseturns it off, and only your human decides that. It covers the requests the sidecar serves itself. A request it relays unraced (its response carriesX-Airmet-Bypass: <reason>: a field the sidecar does not know,response_formatwithoutsupported_params,nabove 1, and a few more, which the troubleshooting table in section 12 lists) goes to the first leg only, as before: nothing is retried on the second. - A refused key takes its leg out. From release 0.11.0, a leg whose key is refused (401, 402 or 403) on two requests in a row is taken out of the pool until a warm-up request with that key succeeds. Some providers answer the model-list probe with 200 whatever the key, so the probe alone never sees a revoked key. Those requests are retried on the other leg like any other failure.
- Keys from environment variables only:
api_key_envnames the variable. policy: twinleg. The policies that cancel the loser (immediate,deferred-p95,deferred-adaptive,tied) race only legs whose cancel billing has a verdict, and direct keys have none, so on them the second leg never fires.- Per-leg request fields go in
extra_body. A request that carries a field the sidecar does not know, such asreasoning_effortormetadata, is relayed to the first leg alone, unraced, and not retried on the second if that leg fails. Set the field on each leg instead. model.nameis what/v1/modelsadvertises. The name is checked, not routed: each leg sends its ownmodel, and a client may send any name.
(a) One marketplace key, each leg pinned to a different provider
replicas:
- id: leg0
engine: openai
base_url: <the marketplace's OpenAI-compatible base URL>
model: <model id on the marketplace>
api_key_env: MARKETPLACE_API_KEY
provider_routing: {only: [<provider-slug-a>], order: [<provider-slug-a>], allow_fallbacks: false}
- id: leg1
engine: openai
base_url: <the same base URL>
model: <the same model id>
api_key_env: MARKETPLACE_API_KEY
provider_routing: {only: [<provider-slug-b>], order: [<provider-slug-b>], allow_fallbacks: false}
model:
name: <the model name your human's app sends>
hedge:
policy: twinleg
alpha: 0.3
Pick a model that two or more providers serve on the marketplace, and keep
allow_fallbacks: false: otherwise both legs can land on the same provider and
the race is no longer independent. provider_routing is forwarded as each
request's provider field on that leg.
(b) Two provider keys, one leg each
replicas:
- id: leg0 # the leg with the higher rate limit
engine: openai
base_url: <provider A's OpenAI-compatible base URL, ending in /v1>
model: <model id at provider A>
api_key_env: PROVIDER_A_API_KEY
extra_body: {reasoning_effort: low} # only for a reasoning model; else delete this line
- id: leg1
engine: openai
base_url: <provider B's OpenAI-compatible base URL, ending in /v1>
model: <model id at provider B>
api_key_env: PROVIDER_B_API_KEY
extra_body: {reasoning_effort: low} # keep both legs the same
model:
name: <the model name your human's app sends>
hedge:
policy: twinleg
alpha: 0.3
(c) Two OpenAI-compatible servers you run
replicas:
- id: leg0
engine: openai # openai even for vLLM or SGLang
base_url: http://<server-1>:8000/v1
model: <model id served by both>
- id: leg1
engine: openai
base_url: http://<server-2>:8000/v1
model: <model id served by both>
model:
name: <model id served by both>
hedge:
policy: twinleg
alpha: 0.3
Two servers on different GPUs or hosts race independent queues; two processes
on one GPU mostly do not. Add api_key_env: NAME to a leg only if that server
requires a bearer key.
Optional: the proxy block
Add it only for a choice your human made:
proxy:
listen: 0.0.0.0:8080 # the default is 127.0.0.1:8080
auth_token_env: AIRMET_TOKEN # required whenever listen is beyond 127.0.0.1
on_unhealthy: error # the default, passthrough, serves from the healthy leg
Check the file
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
grep -n '<' hedger.yaml || echo "no placeholders left"
grep -o 'api_key_env: *[A-Za-z_][A-Za-z0-9_]*' hedger.yaml | awk '{print $2}' | sort -u |
while read -r v; do if [ -n "$(printenv "$v")" ]; then echo "$v: set"; else echo "$v: MISSING"; fi; done
Every placeholder must be gone and every variable set. A variable your human
keeps in a file is loaded in the same command that starts airmet, without
printing it: set -a; . /path/they/gave/you; set +a.
7. Start it
Build the start command from two answers in section 3. Telemetry:
| Your human chose | Start with |
|---|---|
| keep telemetry on | airmet up --config hedger.yaml |
| opt out | airmet up --config hedger.yaml --no-telemetry (saved: later starts stay off until --telemetry) |
| the aggregate format | AIRMET_TELEMETRY_SCHEMA=v1 airmet up --config hedger.yaml (on every start: it is not saved) |
Where the app runs:
| The app runs | Add |
|---|---|
| on this machine | nothing: the sidecar listens on 127.0.0.1:8080 only |
| in a container or on another host | --host 0.0.0.0 --auth-token-env AIRMET_TOKEN, with AIRMET_TOKEN set in the same command (below) |
The second case puts the sidecar on the network, and whoever reaches it without the token could spend your human's keys. Ask first. Make the token once, without printing it, and ask your human where to keep it: the app needs the same value as its API key. In each later command that starts or checks the sidecar, load it from there.
export AIRMET_TOKEN="$(openssl rand -hex 32)"
Start it in the background, with its log in a file:
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
nohup airmet up --config hedger.yaml > airmet.log 2>&1 & # plus your flags from the two tables
echo $! > airmet.pid
for i in $(seq 1 60); do grep -q 'serving /v1' airmet.log && break; kill -0 "$(cat airmet.pid)" 2>/dev/null || break; sleep 1; done
grep -E 'airmet up ·|admitted|failed L0 admission|rescue_solo|serving /v1|^error|address already in use' airmet.log
Expected, with your own run id and model:
airmet up · Go data plane · 2 leg(s) · twinleg α=0.3 · /v1 on 127.0.0.1:8080 · run dir airmet-results/up-…
… replica leg0 admitted L0, …
… replica leg1 admitted L0, …
… rescue_solo ON: a solo turn (alpha gate closed, or the lagging-arm guard) whose leg fails before its first token …
… airmet-hedgerd serving /v1 on 127.0.0.1:8080 (policy=twinleg α=0.3 (immediate, keep_losers) n=2 pool=[leg0 leg1] …)
Check for one admitted line per leg: the banner says 2 leg(s) even when
a leg failed to start. The rescue_solo ON line says a failed first leg will
be retried on the second (a hedger.yaml with hedge.rescue_solo: false has
no such line). If your tool has its own way to run a command in the
background, it can stand in for nohup … &, as long as the log lands in
airmet.log. With telemetry on, the first start also prints the
one-time notice: show it to your human. With --no-telemetry the log reads
telemetry: OFF (flag; saved …).
Ask how it should keep running after your session: a terminal or tmux
of their own, their service manager, or Docker with -d --restart unless-stopped.
Docker
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
docker run -d --name airmet -p 127.0.0.1:8080:8080 \
-v airmet-data:/data \
-v "$HOME/airmet/hedger.yaml:/data/hedger.yaml:ro" \
-e PROVIDER_A_API_KEY -e PROVIDER_B_API_KEY \
airmet:v0.11.2 up --config /data/hedger.yaml # one -e per api_key_env in hedger.yaml
for i in $(seq 1 60); do docker logs airmet 2>&1 | grep -q 'serving /v1' && break; sleep 1; done
docker logs airmet 2>&1 | grep -E 'admitted|failed L0 admission|rescue_solo|serving /v1|^error'
-e NAME passes each key variable from your shell by name, so the value
never appears in the command line. Inside the container the sidecar listens on
every interface and the port mapping decides who reaches it:
127.0.0.1:8080:8080 keeps it on this machine. For an app on another host or
in another container, publish it with -p 8080:8080, and add
-e AIRMET_TOKEN to docker run and --auth-token-env AIRMET_TOKEN after
up. Leave proxy.listen out of a hedger.yaml the container runs. The
airmet-data volume holds the telemetry choice, the install id and the
request records, so every container started on it is the same install: an
opt-out given once with --no-telemetry holds for the containers after it.
Keep -v airmet-data:/data in every docker run of this install. Without it
a new container starts over: it shows the notice again, turns telemetry back
on and counts as a new install. A host directory mounted on /data instead
has to be writable by the image's user, uid 65532; when it is not, the error
says so and how to fix it. -e AIRMET_TELEMETRY_SCHEMA=v1 selects the
aggregate format, on each docker run. Run the other commands on this page as
docker exec airmet /airmet ….
8. Point the app at it
The base URL is http://127.0.0.1:8080/v1. The API key can be any string: the
sidecar sends each leg's own key and ignores the client's, unless it was
started with --auth-token-env. The app keeps its model name. Change only the
setting your human named, and show them the diff.
Python (OpenAI SDK):
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="unused")
r = client.chat.completions.create(model="<model.name>",
messages=[{"role": "user", "content": "hello"}])
print(r.hedge["winner"], r.hedge["n_launched"]) # the SDK keeps unknown fields
Node (OpenAI SDK):
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "http://127.0.0.1:8080/v1", apiKey: "unused" });
const r = await client.chat.completions.create({ model: "<model.name>",
messages: [{ role: "user", content: "hello" }] });
console.log(r.hedge.winner, r.hedge.n_launched);
LiteLLM, as a proxy (config.yaml) or from the SDK:
model_list:
- model_name: <the name your callers use>
litellm_params:
model: openai/<model.name>
api_base: http://127.0.0.1:8080/v1
api_key: unused
import litellm
r = litellm.completion(model="openai/<model.name>", api_base="http://127.0.0.1:8080/v1",
api_key="unused", messages=[{"role": "user", "content": "hello"}])
Tools that read the environment: OPENAI_BASE_URL=http://127.0.0.1:8080/v1
(the OpenAI SDKs and most OpenAI-compatible tools).
A streaming response carries the hedge block in its last chunk.
The app uses wrap() instead. The pip and npm packages also ship
airmet.wrap(client, model=…) (Python) and wrap(client, {model}) (Node),
which start a sidecar themselves when the app calls them. The sidecar logs to
a file, and from release 0.11.0 wrap() relays its first-run telemetry notice
to the app's stderr: once, and once more if what is sent changes. A sidecar
that wrap() reuses prints nothing. The notice arrives only once the app is
running, so take your human's telemetry choice first (section 3). If they opt
out, pass it on: extra_args=["--no-telemetry"] (Python) or
extraArgs: ["--no-telemetry"] (Node).
The app runs in a container or on another host, and section 7 started the
sidecar with --host 0.0.0.0 --auth-token-env AIRMET_TOKEN: the app uses
http://<this host>:8080/v1 and sends the token as its API key
(Authorization: Bearer …).
9. Verify
Run every check. Replace <model.name> with the value in hedger.yaml, and
use your port if it is not 8080.
Started with --auth-token-env? Then /v1 and /admin answer 401
without the token (/readyz and airmet health need none). Define this
helper, which hands curl the token on its standard input so it never appears
on a command line, and use acurl wherever a check below says curl:
acurl() { printf 'header = "Authorization: Bearer %s"\n' "$(printenv AIRMET_TOKEN)" | curl -K - "$@"; }
airmet status cannot send the token in 0.11.2, so it answers
error: …/admin/replicas -> 401. In check 5 use
acurl -s http://127.0.0.1:8080/admin/replicas instead: it lists the
configured, healthy and down legs.
1. Both legs admitted.
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
grep -c ' admitted L0' airmet.log # 2
grep 'failed L0 admission' airmet.log # nothing
With Docker the log is the container's: docker logs airmet 2>&1 | grep -c ' admitted L0'.
2. Ready.
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
curl -s http://127.0.0.1:8080/readyz
airmet health
{"ready":true,"healthy_replicas":2,"configured_replicas":2,…}
ready: true (HTTP 200) healthy_replicas=2 configured_replicas=2 …
3. One request answers, with its hedge block.
curl -s -D - http://127.0.0.1:8080/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"<model.name>","max_tokens":16,"messages":[{"role":"user","content":"Say OK."}]}'
Expect HTTP/1.1 200 OK, an X-Hedge-Winner: leg0 (or leg1) header, an
X-Hedge-Request-Id, and a body ending in a "hedge":{…} object with
n_launched and winner. An X-Airmet-Bypass: <reason> header means the
request was not raced, and says why.
On a reasoning model the 16 tokens can all go to its reasoning. The answer then
reads "content":"" with "finish_reason":"length", and the winning leg's
entry in hedge.arms carries "quality":["empty"]. That is the token cap, not
a failure: this check reads the status, the headers and the hedge block.
4. Requests race. At α = 0.3 about 30% of requests race, so send 20 (at a lower α send more: 50 at α = 0.1):
for i in $(seq 1 20); do
curl -s http://127.0.0.1:8080/v1/chat/completions -H 'Content-Type: application/json' \
-d "{\"model\":\"<model.name>\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Say OK ($i).\"}]}" \
| grep -o '"n_launched":[0-9]'
done | sort | uniq -c
14 "n_launched":1
6 "n_launched":2
The counts vary. At least one "n_launched":2 is expected: none in 20 happens
about once in 1,250 runs at α = 0.3, so run it again before you conclude
anything. A raced request's block reads like this (trimmed):
"hedge": {"n": 2, "n_launched": 2, "policy": "immediate", "winner": "leg0",
"arms": [{"replica": "leg0", "outcome": "won", "ttft_ms": 18.7, "e2e_ms": 92.0},
{"replica": "06GMP9GG3H78CXE9F2PCNFDZK0-leg1", "outcome": "completed_loser"}]}
policy reads immediate under twinleg: that is how twinleg launches a raced
request. The losing leg keeps running, under the name <request id>-<leg>,
and its timings land in the local records
(airmet-results/up-*/raw/requests-0.jsonl) once it finishes.
A request that the second leg took over after the first leg failed also reads
"n_launched":2, but its block carries a rescue object (check 8). With both
legs healthy you should see none.
5. Status. Run it from $HOME/airmet, where the local records are.
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
airmet status
airmet status · http://127.0.0.1:8080
ready: yes (2/2 replicas healthy)
replicas:
• leg0 healthy …
• leg1 healthy …
telemetry: ON → pushed … (or push OFF / OFF, as your human chose)
…
your wins (vs unhedged baseline, from local records):
6. Telemetry is what your human chose.
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
airmet traffic | grep '^consent'
consent: ON (saved choice; …) if they kept it on,
consent: OFF (saved choice; nothing is ever sent) if they opted out.
airmet traffic prints the full inventory, and airmet traffic --verify the
steps to confirm it in a packet capture.
7. A dead leg does not take the app down. Run a second, throwaway sidecar whose second leg points at a closed port, with its own config directory and telemetry off, so your human's saved choice is untouched:
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
cp hedger.yaml hedger.deadleg.yaml
# edit hedger.deadleg.yaml: in leg1, set base_url: http://127.0.0.1:9/v1 (nothing
# listens there) and delete its api_key_env line; in a proxy: block, delete the
# listen and auth_token_env lines and keep on_unhealthy, your human's choice
CHECK="$(mktemp -d)"
AIRMET_CONFIG_DIR="$CHECK/config" XDG_STATE_HOME="$CHECK/state" \
nohup airmet up --config hedger.deadleg.yaml --host 127.0.0.1 --port 8089 \
--no-telemetry --results-dir "$CHECK/results" > "$CHECK/airmet.log" 2>&1 &
echo $! > "$CHECK/pid"
for i in $(seq 1 60); do grep -q 'serving /v1' "$CHECK/airmet.log" && break; kill -0 "$(cat "$CHECK/pid")" 2>/dev/null || break; sleep 1; done
grep -E 'admitted|failed L0 admission' "$CHECK/airmet.log"
for i in $(seq 1 10); do
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8089/v1/chat/completions \
-H 'Content-Type: application/json' \
-d "{\"model\":\"<model.name>\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Say OK ($i).\"}]}"
done | sort | uniq -c
airmet status --admin http://127.0.0.1:8089 | head -6
kill "$(cat "$CHECK/pid")"
… replica leg0 admitted L0, …
… replica leg1 failed L0 admission (excluded from the pool): health (/models) failed
10 200
ready: yes (1/2 replicas healthy)
leg1 DOWN …
Every request is answered from the healthy leg, and the ones that would have
raced carry "passthrough":"degraded_pool" in their hedge block: serving
from the legs that are up is the default (proxy.on_unhealthy: passthrough).
If your human chose on_unhealthy: error, expect 503 (no_agreeing_pool)
instead. The same happens while running: a leg that fails two liveness probes
is demoted (about 20 seconds) and comes back on its own once it answers again.
Until a dead first leg is demoted, the requests that run on it alone are
sent to the second leg (check 8), except those relayed unraced. A leg whose
/models still answers, as in a 429 storm, stays in the pool, and its requests
are sent to the second leg the same way. A leg whose key is refused twice in a
row is taken out (section 6).
With Docker, run the check as a second container on -p 127.0.0.1:8089:8080
with hedger.deadleg.yaml mounted, --no-telemetry, and only leg0's key, and
without the airmet-data volume: on it, that --no-telemetry would be saved
as your human's choice.
8. A first leg that fails is retried on the second. This check needs no
keys and sends nothing to your human's providers. It starts a sidecar of its
own on two throwaway stand-in servers made with python3 (without python3,
skip it and say so), on ports 8090 to 8092, with telemetry off and its own
config directory. It kills the first stand-in and sends 12 requests. With
Docker, skip it too unless a bare binary is installed as well: a container
does not reach the stand-ins on the host's 127.0.0.1.
Run the block as one command: CHECK does not carry over to a fresh shell.
Like section 7, it starts every process with nohup, so none of them dies
with its shell if your tool has to run the block in pieces. Its last line
stops the sidecar and the stand-in that is still running.
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
CHECK="$(mktemp -d)"
cat > "$CHECK/leg.py" <<'EOF'
import json, sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Leg(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args): pass
def reply(self, body, ctype):
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self.reply(b'{"object":"list","data":[{"id":"stub","object":"model"}]}', "application/json")
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length") or 0))
def chunk(delta, fin):
return "data: " + json.dumps({"id": "s", "object": "chat.completion.chunk", "model": "stub",
"choices": [{"index": 0, "delta": delta, "finish_reason": fin}]}) + "\n\n"
sse = chunk({"role": "assistant", "content": "OK"}, None) + chunk({}, "stop") + "data: [DONE]\n\n"
self.reply(sse.encode(), "text/event-stream")
ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Leg).serve_forever()
EOF
cat > "$CHECK/hedger.yaml" <<'EOF'
replicas:
- {id: leg0, engine: openai, base_url: "http://127.0.0.1:8091/v1", model: stub}
- {id: leg1, engine: openai, base_url: "http://127.0.0.1:8092/v1", model: stub}
model: {name: stub}
hedge: {policy: twinleg, alpha: 0.3}
EOF
nohup python3 "$CHECK/leg.py" 8091 > "$CHECK/leg0.log" 2>&1 & echo $! > "$CHECK/leg0.pid"
nohup python3 "$CHECK/leg.py" 8092 > "$CHECK/leg1.log" 2>&1 & echo $! > "$CHECK/leg1.pid"
for i in $(seq 1 20); do
curl -s -o /dev/null http://127.0.0.1:8091/v1/models && curl -s -o /dev/null http://127.0.0.1:8092/v1/models && break; sleep 1
done
AIRMET_CONFIG_DIR="$CHECK/config" XDG_STATE_HOME="$CHECK/state" \
nohup airmet up --config "$CHECK/hedger.yaml" --host 127.0.0.1 --port 8090 \
--no-telemetry --results-dir "$CHECK/results" > "$CHECK/airmet.log" 2>&1 &
echo $! > "$CHECK/pid"
for i in $(seq 1 60); do grep -q 'serving /v1' "$CHECK/airmet.log" && break; kill -0 "$(cat "$CHECK/pid")" 2>/dev/null || break; sleep 1; done
grep -E ' admitted L0|failed L0 admission|rescue_solo ON' "$CHECK/airmet.log" | cut -c1-110
kill "$(cat "$CHECK/leg0.pid")" # the first leg dies
for i in $(seq 1 12); do
curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8090/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"stub","max_tokens":16,"messages":[{"role":"user","content":"Say OK."}]}' |
grep -oE '"kind":"[a-z_]+"|^HTTP [0-9]+' | paste -sd' ' -
done | sort | uniq -c
kill "$(cat "$CHECK/pid")" "$(cat "$CHECK/leg1.pid")"
… replica leg0 admitted L0, …
… replica leg1 admitted L0, …
… rescue_solo ON: a solo turn (alpha gate closed, or the lagging-arm guard) whose leg fails before its …
8 "kind":"solo_failed" HTTP 200
4 HTTP 200
Every request answers 200, although the first leg is gone. The ones that ran
on the first leg alone (about 70%) carry this in their hedge block:
"rescue":{"kind":"solo_failed","from":"leg0","to":"leg1"}
The rest raced, and the second leg won. The counts vary. Send the 12 requests
right after the kill: the first leg is taken out of the pool within about 20
seconds, and the requests after that are not retries any more. No solo_failed
in 12 would mean the retry is not running: read the rescue_solo line of the
log, and check that the config has policy: twinleg and no
hedge.rescue_solo: false.
9. The app goes through it. Note the count of local records, run one request from the app itself, and check that the count grew by one:
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
wc -l "$(ls -td airmet-results/up-* | head -1)/raw/requests-0.jsonl"
With Docker the records are in the airmet-data volume:
docker exec airmet /airmet status | grep 'source:' reads
source: <N> request(s) from /data/airmet-results/…. With Docker and a token,
status cannot authenticate (above): check instead that the app's response
carries the hedge field or an X-Hedge-Request-Id header.
10. Optional: link this machine (airmet login)
Only if your human asked for it (section 3, question 10):
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
airmet login
It prints a link and a short code, then waits. Give both to your human; they
open the link in any browser, sign in and confirm the code. The command ends
with linked to <org> — token saved in …/config.json. If your shell cannot
leave a command waiting, ask your human to run airmet login in their own
terminal. Then restart airmet up (section 11) and check:
cd "$HOME/airmet" && export PATH="$HOME/airmet/bin:$PATH"
airmet login --status # linked: <org> — ingest token saved in …
Linking turns on the live route feed, the fleet-tuned routing data that a
running install refreshes. A linked install sends its token and install id
whenever it registers or polls the feed, and the public catalog routes its legs
use when it polls the route feed, whatever the telemetry choice.
airmet login --clear unlinks it.
11. Stop, restart, upgrade, uninstall
Stop. kill "$(cat "$HOME/airmet/airmet.pid")". The log ends with
airmet-hedgerd stopped cleanly. Docker: docker stop airmet.
Restart. Start it again exactly as in section 7. The saved telemetry
choice applies; AIRMET_TELEMETRY_SCHEMA=v1 has to be given again. Docker:
docker start airmet restarts the same container, with its saved choice and
install id (docker run --name airmet again would fail: the stopped container
still holds the name).
Upgrade. Fetch this page again (https://airmet.ai/install-for-agents.md): it names the
current release. Repeat section 5 with it, stop the old process, start the new
one as in section 7 and run section 9 again. hedger.yaml carries over.
Docker: remove the old container first (docker rm -f airmet), then run
section 7's docker run with the new image tag. The new container keeps the
telemetry choice and the install id from the airmet-data volume. A container
started without that volume (the 0.11.0 command) kept them inside itself, and
they go with it: pass your human's choice once more on the first docker run
with the volume.
Uninstall. Ask first, then:
- Stop it, and put the app's base URL back the way it was.
- Remove what you installed:
rm -rf "$HOME/airmet/bin"(every channel but Docker), plusrm -rf "$HOME/airmet/.venv" "$HOME/airmet/wheels"(pip),rm -rf "$HOME/airmet/node_modules" "$HOME/airmet/package.json" "$HOME/airmet/package-lock.json"(npm),docker rm -f airmet && docker rmi airmet:v0.11.2(Docker). - Only if your human wants it gone too, the local state:
~/.config/airmet/holds the telemetry choice, the install id and any login token ($XDG_CONFIG_HOME/airmet/when that is set);~/.local/state/airmet/holds caches ($XDG_STATE_HOME/airmet/);$HOME/airmet/airmet-results/holds the local request records. Docker keeps all three in its volume:docker volume rm airmet-data. Runairmet login --clearbefore you delete a linked install's config.
12. Troubleshooting
| You see | It means | Do |
|---|---|---|
error: config references key env var $NAME, which is empty or unset in this shell |
The key's variable is not set in the shell that started airmet |
Load the keys in the same command that starts it (section 6). Ask your human where they keep them |
replica legN failed L0 admission (excluded from the pool): health (/models) failed |
The leg did not answer GET /models at startup: a wrong base URL, a dead or wrong key (401), a host that is down, or a 429 |
Test the leg without printing its key (below). A 401 means the key: ask your human for a working one |
failed L0 admission … warmup chat request failed: legN /chat/completions HTTP 402: … |
The account behind that key is out of credit | Tell your human. Do not move the leg to another provider on your own |
… HTTP 404 … in that line |
The model id is wrong for that provider | Confirm the model id with your human |
… HTTP 429 … at startup, or clients getting 429s |
A rate limit. A request that does not race and gets a 429 from the first leg is sent once to the second ("kind":"solo_failed"), so a client sees the 429 only when the second leg refuses it too, or the request has an X-Airmet-Bypass header (relayed unraced, never retried) |
Put the leg with the higher limit first, lower the request rate, or ask your human about their rate tier |
"rescue":{"kind":"solo_failed",…} or "kind":"solo_hung" in a response |
The first leg failed before its first token (solo_failed), or sent nothing at all for 15 s (solo_hung), and the second leg answered |
Nothing to fix in Airmet. airmet.log has the first leg's error on an arm … failed line; tell your human if it repeats |
health: replica legN demoted: 2 consecutive requests refused with HTTP 401/402/403 |
That leg's key is revoked, invalid or out of credit, though its /models still answers. The pool took the leg out |
Tell your human which leg, never the key. Out of credit: they top it up and the leg returns once a warm-up request succeeds. Revoked: they put a new key in the environment and you restart airmet up (section 11). Do not move the leg to another provider on your own |
no openai replicas passed L0 admission among [leg0 leg1], and airmet up exits |
No leg is up, so it does not start | Fix the legs first, one at a time |
No "n_launched":2 in 40 requests |
Nothing is racing | The banner must read twinleg α=… with α above 0, and airmet status 2/2. An X-Airmet-Bypass header names the reason per request: unknown_key means a request field to move into each leg's extra_body. response_format, logprobs, logit_bias and prediction requests are relayed unraced unless the leg declares supported_params. From release 0.11.1, top_k and repetition_penalty join that list, and logprobs, top_logprobs and plugins requests are always relayed unraced |
"passthrough":"degraded_pool" in responses |
Only one leg is healthy, so nothing races | airmet status names the leg that is DOWN; its admission line says why |
serve: listen tcp 127.0.0.1:8080: bind: address already in use |
Something already listens on 8080 (the log may print serving /v1 just before this) |
lsof -nP -iTCP:8080 -sTCP:LISTEN shows what. Ask before you stop it, or start with --port 8090 and use that port in the base URL |
| Legs time out or fail to connect behind a corporate proxy | From release 0.11.1 the requests to the legs go through HTTPS_PROXY / HTTP_PROXY and honour NO_PROXY, and localhost always goes direct. Release 0.11.0 connected to the legs directly and ignored them |
Check that the proxy variables are set in the environment airmet up runs in: the boot log prints one leg legN (<host>) egress: line per leg, naming the proxy or why that leg goes direct. A leg that goes direct when it should not is usually in NO_PROXY. Still failing, or on 0.11.0: stop and tell your human. It needs the proxy's address, a host with direct egress to the providers, or a leg pointed at an internal OpenAI-compatible gateway it can reach |
x509: certificate signed by unknown authority |
A TLS-inspecting proxy re-signs the providers' certificates | Ask your human for the corporate CA bundle. On Linux start with SSL_CERT_FILE=/path/to/bundle.pem; macOS reads the system keychain. Never turn verification off |
error: externally-managed-environment from pip |
The system Python refuses package installs | Use the virtual environment from section 5, never --break-system-packages |
ensurepip is not available or No module named venv |
That Python has no venv module | Ask your human to install it (on Debian and Ubuntu, python3-venv), or use the bare binary |
No matching distribution found for airmet==… |
No wheel for this platform (musl, 32-bit) | Use the bare binary |
| macOS: "cannot be opened because the developer cannot be verified" | The bare binary is not notarized yet, and this copy is quarantined (a browser download) | Download it with curl as in section 5, or use pip or npm. Do not change quarantine attributes yourself |
Docker: requested image's platform (linux/amd64) does not match |
An arm64 host is emulating the image | Prefer the bare binary on this host |
Clients get 401 from the sidecar |
It was started with --auth-token-env, and the client sent no token or the wrong one |
Set the app's API key to the token |
Clients get 503 with no_agreeing_pool |
on_unhealthy: error, with a leg down |
Fix the leg, or ask your human about the default passthrough |
404 model_not_found |
model.strict: true, and the app sends a name outside model.name and model.aliases |
Ask your human: add the name to aliases, or drop strict |
Test one leg without putting its key on a command line (printf is a shell
built-in, so the key reaches curl on its standard input):
printf 'header = "Authorization: Bearer %s"\n' "$(printenv PROVIDER_A_API_KEY)" |
curl -sK - -o /dev/null -w '%{http_code}\n' "<leg0 base_url>/models"
200 is a working key and URL, 401 or 403 a key problem, 404 a wrong
base URL, 000 no connection.
Done when
Report this checklist to your human, one line each, with the evidence:
- Installed from
https://airmet.ai/dl/v0.11.2/: the channel,airmet versionoutput, and theSHA256SUMScheck that printedOK. -
hedger.yamlin$HOME/airmet: the setup (a, b or c), leg idsleg0andleg1, no key in the file,policy: twinleg, the α your human chose. - Telemetry as your human chose: the start flag used, and the
airmet trafficconsent line. - One
admittedline per leg, andairmet statusreads2/2 replicas healthy. - A raced request seen: the
"n_launched":2count out of 20. - The dead-leg check answered 200 from the healthy leg (or 503, if your
human chose
error), and the check's sidecar is stopped. - The retry check (check 8): the
solo_failedcount out of 12 and therescue_solo ONlog line, or why it was skipped. - The app's base URL changed (the diff), and one request from the app itself appeared in the local records.
- How it keeps running (whose terminal, service or container), and
whether this machine is linked (
airmet login --status). - Anything that failed twice, with its exact error line, keys redacted.