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 needWhy
Docker with ComposeThe profile brings up the gateway, Postgres and a stub IdP together
A Token Factory API keyThe gateway custodies it; applications never hold one
Your Factory's origin URLOrigin only, no path — the client appends the API paths itself
Your contract's model ratesThere 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 loopbackApplications 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>
VariableWhat it is
TF_KEYThe upstream credential. Custodied by the gateway, never handed to an application
IAG_ADMIN_TOKENBearer for /admin/v1 and the console. Treat it as root for the gateway
IAG_SIGNING_KEYSigns virtual keys; 32 bytes or more. Rotating it invalidates every key already minted
IAG_UPSTREAM_BASE_URLOrigin 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."}]}'

Response headers #

Every response explains itself. These are computed per request, never constants.

HeaderMeaning
x-iag-model-routedThe concrete model that actually ran
x-iag-route-reasonWhy it was chosen, including any model excluded and why
x-iag-cacheexact or miss
x-iag-cost-usdPriced from the upstream's usage block against your catalog rates
x-iag-cost-basismetered, or estimated when the usage block never arrived
x-iag-savings-usdAgainst the workspace's declared baseline model
x-iag-tokens-savedAn estimate from the trim stage, and labelled as one
x-iag-upstream-cached-tokensPrompt tokens the provider served from its own prefix cache
x-iag-verdict · x-iag-confidence · x-iag-checksThe verification outcome, its score, and which checks ran
x-iag-trace-idJoins 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
RuleWhy it is a rule
A model absent from the catalog bills at floor_rateAn unpriced model must never meter as free. The record is flagged rate: floor
A Factory model priced at zero is refused at bootA free model hides spend
cached_input_per_1m unset means no discountCost is never understated by a rate your contract has not confirmed
Tiers drive the aliasesiag/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 #

ChangeWhy
Terminate TLS at your internal load balancerNeither listener terminates TLS itself
Keep 8081 off the application network entirelyIt mints keys, moves budgets and rewrites policy
Replace the stub IdP with your own discovery URLThe stub exists so a local boot passes the IdP check. It is not for production
Leave upstream.probe.required at its defaultThe compose profile relaxes it so a local boot survives an unreachable upstream
Move secrets to your secret storeEnvironment variables are the local-bring-up path, not the deployment pattern
Back up PostgresKeys, policy, budgets, records, approvals and the evidence ledger all live there

Troubleshooting #

SymptomCause
Container exits naming a config fieldWorking as designed. Fix that field; it will not boot degraded
Every virtual key returns 401 after a rebuildA regenerated IAG_SIGNING_KEY invalidates previously minted keys. Mint new ones
Boot refuses because two addresses are equaladmin.addr and http.addr must differ. That check is a security control
Cost shows at the floor rateThe routed model is not in your catalog. Add it with your contract rates
x-iag-cost-basis: estimatedThe usage block never arrived, usually an aborted stream. Billed at the abort floor and flagged
503 model_unavailableA pinned model whose declared upstream is not answering. Use an alias to fall through
Console shows nothingYou are on 8080. The console is on the admin listener, 8081