Installation guide
A working governed gateway against your own Token Factory, from nothing, in about ten minutes. Every command here was run against a live upstream while writing this page.
Source and container images are distributed to customers and partners under licence. This guide assumes you have the repository. It does not publish the build.
Before you start #
| You need | Why |
|---|---|
| Docker with Compose | The profile brings up the gateway, Postgres and a stub IdP together |
| A Token Factory API key | The gateway custodies it; applications never hold one |
| Your Factory's origin URL | Origin only, no path — the client appends the API paths itself |
| Your contract's model rates | There is no mock price list. A model absent from the catalog bills at the floor rate and is flagged on every record |
| A free port 8080, and 8081 on loopback | Applications and operators get separate listeners |
Bring it up #
The four secrets #
All four are required and none has a default. Compose refuses to start without them, which is deliberate: an admin surface that boots unauthenticated by accident is worse than one that does not boot.
export TF_KEY=<your Token Factory key>
export IAG_ADMIN_TOKEN=$(openssl rand -hex 24)
export IAG_SIGNING_KEY=$(openssl rand -hex 32)
export IAG_UPSTREAM_BASE_URL=https://<factory-origin>
| Variable | What it is |
|---|---|
TF_KEY | The upstream credential. Custodied by the gateway, never handed to an application |
IAG_ADMIN_TOKEN | Bearer for /admin/v1 and the console. Treat it as root for the gateway |
IAG_SIGNING_KEY | Signs virtual keys; 32 bytes or more. Rotating it invalidates every key already minted |
IAG_UPSTREAM_BASE_URL | Origin only. A path here would produce a doubled path on every call, so config refuses it |
docker compose -f deploy/lite/docker-compose.yml up --build
To check the compose file without a Docker daemon at all:
TF_KEY=x IAG_ADMIN_TOKEN=x IAG_SIGNING_KEY=x \
docker compose -f deploy/lite/docker-compose.yml config
Watching it boot #
Boot is fail-closed and says what it did. A real boot against a live Factory looks like this:
INFO config loaded config_sha256=507885ca… audit=config.loaded
INFO migrations complete applied="[0001_init 0002_system_prompt 0003_governed_gateway …]"
INFO upstream credential resolved source=env key_source=env:TF_KEY
INFO upstream state transition from_state=unknown to_state=ready origin=boot
INFO listening addr=0.0.0.0:8080 governed_gateway=true
INFO admin listening addr=0.0.0.0:8081 note="admin API + console"
An unset admin token, a signing key under 32 bytes, a missing upstream credential, an unreachable database or IdP each exit non-zero naming the config field, and Compose restarts the container until boot passes. The upstream probe is a zero-spend call: it classifies the Factory as ready without buying a single token.
curl -s localhost:8080/healthz # liveness
curl -s localhost:8080/readyz # readiness, carries the classified upstream state
First requests #
Workspace, policy, key #
Three calls against the admin listener on 8081. A workspace maps to one upstream project; a policy says what that workspace may do; a virtual key is what an application actually holds.
A="Authorization: Bearer $IAG_ADMIN_TOKEN"
B=http://127.0.0.1:8081/admin/v1
curl -s -H "$A" -X POST $B/workspaces \
-d '{"name":"ws-pilot","tf_project_id":"proj-1"}'
curl -s -H "$A" -X PUT $B/policy/ws-pilot -d '{
"allowed_models":["openai/gpt-oss-20b","openai/gpt-oss-120b"],
"features":{"routing":true,"cache":true,"trim":true,"verify":true,"tool_gating":false},
"baseline_model":"openai/gpt-oss-120b",
"budget_posture":"fail-open"}'
KEY=$(curl -s -H "$A" -X POST $B/keys \
-d '{"workspace":"ws-pilot","principal":"alice","name":"laptop","ttl_hours":24}' \
| jq -r .token)
The key token is returned once. Only its prefix is stored, so it cannot be recovered later — mint a new one instead.
Calling the gateway #
Any OpenAI or Anthropic SDK works unchanged: point its base URL at the application listener on 8080 and give it the virtual key.
curl -si localhost:8080/v1/chat/completions \
-H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"model":"iag/auto","messages":[{"role":"user","content":"Say hello."}]}'
curl -s localhost:8080/v1/messages \
-H "x-api-key: $KEY" -H 'content-type: application/json' \
-d '{"model":"iag/fast","max_tokens":64,
"messages":[{"role":"user","content":"Say hello."}]}'
OPENAI_BASE_URL=http://localhost:8080/v1
OPENAI_API_KEY=$KEY
No client library changes, no wrapper package. The gateway is an OpenAI-compatible origin, and streaming works on both surfaces.
Response headers #
Every response explains itself. These are computed per request, never constants.
| Header | Meaning |
|---|---|
x-iag-model-routed | The concrete model that actually ran |
x-iag-route-reason | Why it was chosen, including any model excluded and why |
x-iag-cache | exact or miss |
x-iag-cost-usd | Priced from the upstream's usage block against your catalog rates |
x-iag-cost-basis | metered, or estimated when the usage block never arrived |
x-iag-savings-usd | Against the workspace's declared baseline model |
x-iag-tokens-saved | An estimate from the trim stage, and labelled as one |
x-iag-upstream-cached-tokens | Prompt tokens the provider served from its own prefix cache |
x-iag-verdict · x-iag-confidence · x-iag-checks | The verification outcome, its score, and which checks ran |
x-iag-trace-id | Joins the response to its record and to the upstream's own request id |
What happened, from the admin API or the console at
http://127.0.0.1:8081/:
curl -s -H "$A" "$B/requests?workspace=ws-pilot" | jq .
curl -s -H "$A" "$B/overview?workspace=ws-pilot&window=24h" | jq .
Configuration #
deploy/lite/config.yaml is the compose-profile config; the fully commented
schema is iag-lite/config.example.yaml. Every secret is named by
environment variable, never written in the file.
The catalog #
This is the one section you must edit. Put in the models your Factory project serves and the rates on your contract.
catalog:
floor_rate: {input_per_1m: 15.0, output_per_1m: 60.0}
models:
- id: openai/gpt-oss-20b
tier: small
context_length: 128000
capabilities: [chat, tool-use, json-mode]
input_per_1m: 0.05
output_per_1m: 0.20
# cached_input_per_1m: 0.005 # set from your contract; unset claims no discount
| Rule | Why it is a rule |
|---|---|
A model absent from the catalog bills at floor_rate | An unpriced model must never meter as free. The record is flagged rate: floor |
| A Factory model priced at zero is refused at boot | A free model hides spend |
cached_input_per_1m unset means no discount | Cost is never understated by a rate your contract has not confirmed |
| Tiers drive the aliases | iag/fast and iag/quality resolve through them |
Optimisers #
routing:
aliases: {iag/auto: cascade, iag/fast: small, iag/quality: large}
max_escalations: 1
baseline_model: z-ai/glm-5.2 # what savings are measured against
cache_ttl: 10m
canon:
enabled: true # keep the cacheable prefix byte-stable
volatile_fields: [timestamp, request_id, nonce, trace_id]
trim:
retained_verbatim_turns: 6
min_prompt_tokens: 32000 # the trimmer does not run below this
verify:
judge: {enabled: false, model: "", max_usd_per_check: 0.002}
trim.min_prompt_tokens is high on purpose. Trimming a conversation
destroys the provider's cached prefix, and the measurement showed that costs more
than it saves at any cached discount a provider actually offers. See the
evidence.
A local model on the small tier #
Optional, off by default. Run any OpenAI-compatible server, declare it as a catalog
model, and iag/fast lands there instead of the Factory.
# on the host, not in the container
mlx_lm.server --model mlx-community/Qwen3-4B-Instruct-2507-4bit --host 0.0.0.0 --port 8090
- id: local/qwen3-4b
tier: small
context_length: 32000
capabilities: [chat, tool-use, json-mode]
input_per_1m: 0
output_per_1m: 0
upstream:
base_url: "http://host.docker.internal:8090"
model: "mlx-community/Qwen3-4B-Instruct-2507-4bit"
Nothing else changes. The model is allowed by workspace policy like any other, and the
catalog shows it as local:host.docker.internal:8090 because prompt content
reaches that endpoint. Zero rates are accepted here and only here: a model with its own
upstream may honestly cost nothing per token, while a Factory model may not.
Prompt content reaches the endpoint you declare. Keep it inside your network. The gateway validates the URL shape and refuses the Factory's own origin and credential, but it cannot tell whether the host you named is inside your VPC.
Going to production #
| Change | Why |
|---|---|
| Terminate TLS at your internal load balancer | Neither listener terminates TLS itself |
| Keep 8081 off the application network entirely | It mints keys, moves budgets and rewrites policy |
| Replace the stub IdP with your own discovery URL | The stub exists so a local boot passes the IdP check. It is not for production |
Leave upstream.probe.required at its default | The compose profile relaxes it so a local boot survives an unreachable upstream |
| Move secrets to your secret store | Environment variables are the local-bring-up path, not the deployment pattern |
| Back up Postgres | Keys, policy, budgets, records, approvals and the evidence ledger all live there |
Troubleshooting #
| Symptom | Cause |
|---|---|
| Container exits naming a config field | Working as designed. Fix that field; it will not boot degraded |
| Every virtual key returns 401 after a rebuild | A regenerated IAG_SIGNING_KEY invalidates previously minted keys. Mint new ones |
| Boot refuses because two addresses are equal | admin.addr and http.addr must differ. That check is a security control |
| Cost shows at the floor rate | The routed model is not in your catalog. Add it with your contract rates |
x-iag-cost-basis: estimated | The usage block never arrived, usually an aborted stream. Billed at the abort floor and flagged |
503 model_unavailable | A pinned model whose declared upstream is not answering. Use an alias to fall through |
| Console shows nothing | You are on 8080. The console is on the admin listener, 8081 |