Use an LLM Gateway (LiteLLM)¶
An LLM gateway such as LiteLLM sits between your AI tools and the model providers. It holds the provider keys, gives each person or project its own key with a budget, and logs usage in one place.
This page shows how to let AI tools in the container reach a gateway, including
tools other than Claude Code. The image does not change: you only edit the
project's .devcontainer/ files. Claude Code stays installed; the claude CLI
makes no network calls unless you start it.
Every step uses commented-out placeholders that ship in the examples/
templates. Search for llm-gateway.example.com and OPENAI_ to find them.
What the gateway needs¶
- HTTPS with a publicly trusted certificate. All container traffic passes through the inspection proxy (mitmproxy), which checks the gateway's certificate against the image's system CA bundle. Only root can change that bundle, so a gateway behind a private CA fails TLS. Supporting a private CA needs an image change. Any port works.
- An OpenAI-compatible endpoint:
/v1/chat/completions, and/v1/responsesfor tools that use it. LiteLLM serves both. - Models that support tool calling (function calling). Coding agents don't work without it.
- One key per person or project. In LiteLLM, that is a virtual key limited to the models the project needs, with a budget and an expiry. Store it in 1Password.
A minimal LiteLLM setup looks like this. See the LiteLLM docs for every option.
# LiteLLM config.yaml (runs on the gateway host, not in this container)
model_list:
- model_name: coding-model # the name your tools ask for
litellm_params:
model: <provider>/<model-id> # an Anthropic, OpenAI, Bedrock, Azure, ... model
api_key: os.environ/PROVIDER_API_KEY
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
database_url: os.environ/DATABASE_URL # required for virtual keys
# Create a virtual key for one project (run by the gateway admin)
curl -s https://llm-gateway.example.com/key/generate \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{"models": ["coding-model"], "max_budget": 50, "duration": "30d"}'
Step 1: allow the gateway¶
Add the gateway hostname to both egress layers, as for any other host (see Manage Egress).
.devcontainer/egress-allowlist.txt:
.devcontainer/proxy.yaml. The # tag:llm comment makes the dashboard mark the
traffic as LLM calls:
The firewall resolves hostnames once, at container start. For a gateway on an
internal network, connect the VPN before starting the container. Otherwise
start-up prints WARN: Could not resolve: llm-gateway.example.com and the
gateway stays blocked. Reload either layer without a restart:
Hosted gateway: OpenRouter¶
OpenRouter is a hosted gateway with an
OpenAI-compatible API at https://openrouter.ai/api/v1, so there is nothing to
run yourself. The templates ship a commented block for it, next to the one for
llm-gateway.example.com. Uncomment openrouter.ai in both
.devcontainer/egress-allowlist.txt and .devcontainer/proxy.yaml, reload the
layers as above, then choose "OpenRouter" as the provider in the Cline extension
(the templates install it). Cline takes the key in its own settings, so Step 2
does not apply to it.
OpenRouter sits behind Cloudflare, so its IPs can change; if calls start
failing, run sudo /usr/local/bin/firewall.sh apply again. Prompts and code you
send leave your network and pass through OpenRouter to the model provider, so
check that this fits your data policy first. Use a separate key per person or
project, and keep it in 1Password.
Step 2: pass the URL and key¶
Uncomment the LLM gateway block in .devcontainer/env.sh, and point it at your
gateway and your 1Password item:
export "MY_PROJECT_OPENAI_BASE_URL"="https://llm-gateway.example.com/v1"
export "MY_PROJECT_OPENAI_API_KEY"="$(op read "op://${OP_VAULT}/${PROJECT_PREFIX_LOWER}_llm_gateway/api_key")"
Then uncomment the matching lines in .devcontainer/devcontainer.json:
"OPENAI_BASE_URL": "${localEnv:MY_PROJECT_OPENAI_BASE_URL}",
"OPENAI_API_KEY": "${localEnv:MY_PROJECT_OPENAI_API_KEY}",
Source env.sh again and reopen the container from that same terminal.
Many tools read OPENAI_BASE_URL and OPENAI_API_KEY. Others, including most
VS Code extensions, take the URL and key in their own settings instead. Enter
the same values there.
Step 3: add an AI tool¶
VS Code extension (simplest)¶
Add the extension's Marketplace ID to customizations.vscode.extensions in
devcontainer.json, then rebuild the container. The Marketplace is not in the
egress allowlist, but VS Code still installs the listed extensions. This was
checked in this container: the server's own Marketplace requests are refused,
and the extensions still install.
The extension runs inside the container, so its requests go through the proxy chain like everything else.
Command-line tool¶
The image ships two command-line agents: Claude Code (claude) and the GitHub
Copilot CLI (copilot). The Copilot CLI talks to GitHub's own Copilot service,
not to a gateway, so it needs the "GitHub Copilot" allowlist entries. You can
install another agent into your home directory at runtime if you first add its
package registry (for example registry.npmjs.org or pypi.org) to both
allowlists. It is lost when the container is rebuilt. To have one permanently,
open an issue asking for it to be added to the image.
Before you pick a tool¶
Check four things:
- Custom endpoint. The tool accepts a custom OpenAI-compatible base URL.
- AGENTS.md. The tool reads
AGENTS.md, or you can point it at that file. The safety rules every agent must follow live in AGENTS.md## Agent Guardrails.CLAUDE.mdonly repeats them for Claude Code. - System CA bundle. The tool trusts the system certificates. The container
sets
SSL_CERT_FILE,CURL_CA_BUNDLE, andNODE_EXTRA_CA_CERTS, which covers Node.js, curl, and most Python tools. A tool that ships its own list of root certificates fails every HTTPS request through the inspection proxy. - Approval mode. The tool can ask before it runs commands. Keep that on, so the confirmations required by the guardrails reach you.
Step 4: verify¶
# Gateway reachable through both layers (LiteLLM health check)
curl -s -o /dev/null -w '%{http_code}\n' https://llm-gateway.example.com/health/liveliness
# Key accepted; lists the models this key may use
curl -s https://llm-gateway.example.com/v1/models \
-H "Authorization: Bearer $OPENAI_API_KEY" | jq -r '.data[].id'
Then use the tool once and open the dashboard (http://localhost:9090). The
gateway host should appear in the Proxy Feed with the LLM badge. If it does
not appear, the tool is not using the proxy.
Optional: send all AI traffic through the gateway¶
Leave the Anthropic / Claude Code entries commented out in both allowlists.
That is the default in the examples/ templates. The firewall then rejects
direct calls to Anthropic, so the gateway is the only way to reach a model.
Other providers' APIs (such as api.openai.com) are not in the templates at
all.
Limitations¶
- No token counts. The dashboard's token and cost counters only read
Anthropic and GitHub Copilot traffic (
mitm/addon.py). Gateway calls appear in the Proxy Feed, without token counts. Use LiteLLM's own spend tracking. - No private CA without an image change; see What the gateway needs.
- Changing gateway IPs. The firewall keeps the IP addresses it resolved at
start-up. If the gateway's IPs change (common behind cloud load balancers),
run
sudo /usr/local/bin/firewall.sh applyagain. - Claude Code through the gateway is a separate setup and is not covered
here. It uses the Anthropic-format endpoint and the
ANTHROPIC_BASE_URLandANTHROPIC_AUTH_TOKENvariables.
See ADR 0018 for why this is configuration-only.