Every model call on our lab’s private AI server goes through one gateway: one key per client, a token budget checked before the GPU sees anything, a trace for every request, and no local model allowed to fall back to the cloud. Here is how we built it, what went wrong, and how to set it up yourself.
Where this fits · Running Local AI in Production · Part 2 of 11
You are reading Part 2 of 11 in a series where we built a private AI server in our lab and ran realistic test workloads on it for seven weeks. Each part stands on its own. The previous part covered why we built the lab on our own hardware, what ran on it and what that choice cost; this one covers one LLM gateway as the only way to reach a model: keys, budgets and tracing.
← Part 1: Local AI or Cloud AI? Why We Built a Private AI Server · Series guide · Part 3: Why Does a Local AI Server Freeze? Sizing Memory for LLMs →
Just want it working? Follow the step-by-step setup guide: local AI with Ollama and LiteLLM, from a bare server to a secure gateway.
See all 11 parts of the series
- Local AI or Cloud AI? Why We Built a Private AI Server: why we built the lab on our own hardware, what ran on it and what that choice cost
- How Do You Control Access to Local AI Models? Use a Gateway (you are here): one LLM gateway as the only way to reach a model: keys, budgets and tracing
- Why Does a Local AI Server Freeze? Sizing Memory for LLMs: five hard freezes and the memory budget that ended them
- What Happens When a Prompt Is Too Big for a Local LLM?: fitting every prompt to a local model’s context window
- How Do You Run Several AI Models on One GPU Without Slowdowns?: routing requests across models on a single GPU without constant model swaps
- How Do You Keep an AI Server Stable? GitOps for Local AI: running the server from a Git repository: deploys, drift, pinning and safe AI agents
- What Is Your Local AI Server Actually Doing? Observability Basics: making the server observable: traces, dashboards, usage and heat
- Can Local AI Agents Analyse Stocks? How We Tested and Scored Them: a multi-agent stock-analysis experiment and how we scored the agents
- Why Was Our Telegram AI Agent So Slow? The Hidden Prompt Cost: a Telegram AI agent on local models, and why its fixed prompt became the budget
- Can AI Automate Business Workflows Without Sending Data Out?: document, email and knowledge-base workflows that keep data on the server
- Is Private AI Worth It? Lessons From Seven Weeks in Our Lab: the lessons from seven weeks, the honest limits and what comes next
This series documents a private AI server we built and stress-tested in our lab. Workloads and data are synthetic or generalised; the problems, measurements and fixes are real.
TL;DR
- How do you control access? Put one gateway in front of every model and make it the only way in, so every call has a named key, a budget check and a trace.
- The problem: clients could reach our lab’s models three different ways, nearly everything shared one all-powerful admin key, and oversized prompts were silently cut short.
- Our approach: make LiteLLM, an open-source gateway, the only door, with one key per client, a token budget checked before the GPU sees a request, and no local model allowed to fall back to a cloud model.
- The result: a 45,183-token request is refused in milliseconds, every call is traced to a named key, and a pre-commit check refuses any configuration that could send data to the cloud.
- The lesson: a fallback belongs to the model, not the key, and a server that refuses is better than a guard that alerts.
Jargon buster: 15 terms used in this post, explained in plain English
- Slot (parallel requests): One request a model server can work on at the same time. More slots serve more people at once but reserve more memory.
- Ollama: Free, open-source software that downloads and runs AI models on your own machine and answers requests over a local API.
- LiteLLM: Open-source software that puts one front door in front of many AI models, with per-user keys, budgets, limits and logging.
- LLM gateway: A single service every AI request must pass through, so access, budgets and logging are controlled in one place.
- API key / virtual key: A secret string that identifies the program making a request. Giving each program its own key lets you limit and track it separately.
- Langfuse: Open-source software that records every AI request (prompt, answer, timing and cost) so you can see what happened and why.
- Trace: A detailed record of one request as it passes through each part of a system, used to find where time or errors come from.
- Telegram bot: An automated account on the Telegram messaging app that people chat with; here, the front end for an AI agent.
- OpenRouter: A cloud service that offers many AI models from different providers through one API, including some free models.
- Docker / container: A way of packaging an application with everything it needs so it runs the same anywhere; each packaged application runs in its own container.
- Pinning: Locking software to an exact version so it can’t change unexpectedly during an update.
- Pre-commit check: An automatic check that runs before a change is saved to the repository and blocks it if it breaks a rule.
- Loopback: A network address that only the server itself can reach, so a service listening there isn’t exposed to the network.
- HTTP status codes (400, 413, 429): Standard codes a server returns: 400 means a bad request, 413 means it’s too large, 429 means too many requests.
- PostgreSQL / ClickHouse / Redis: Open-source databases: PostgreSQL for general records, ClickHouse for fast analysis of large logs, Redis for fast short-term data.
The problem: three ways in, one key for everything
In Part 1 we built a private AI server in our lab, with local models served by Ollama. Its first, hand-built version had a basic control problem: a client could reach a model three ways. It could go through LiteLLM, the gateway meant to be the front door. It could go straight to Ollama, which listened on the lab network. Or it could use a second backend inside Open WebUI, the chat UI, which talked to Ollama directly.
Only the first path knew who was calling. On the second day of our reorganisation, minutes after the server came back from a hard freeze, a test laptop’s IDE plugin called Ollama directly and loaded nemotron-3-nano:30b, the model whose loading had come just before the earlier freezes. We had removed it from the gateway’s routing the day before, which did nothing for a client that never used the gateway.
Keys were just as loose. Nearly everything used the gateway’s admin key, which can create and delete keys and call every model, and copies of it had spread into old IDE templates and a workflow. In a real business that is a privacy and reliability risk at once: you can’t tell who sent what, you can’t limit anyone, and one leaked key opens everything.
What we were aiming for
- One door: every model call goes through the gateway, and nothing else on the network can reach a model.
- One key per client, with its own model list and limits, and the admin key used for administration only.
- Oversized prompts refused or trimmed before they reach the GPU, never silently truncated.
- No path, deliberate or accidental, by which local data reaches a cloud model, and a trace for every call.
How we approached it
An LLM gateway is a proxy between clients and models. Every client talks to one OpenAI-compatible URL, and the gateway decides which backend answers. It is the one place where identity, policy, budgets, concurrency and tracing can all live, so a path that skips it skips all five. We chose LiteLLM because it is open source, speaks the OpenAI API that most tools already support, and has virtual keys, each with its own alias, model list, rate limit and optional budget.
We moved the clients first and closed the side doors after, because closing a path before moving its users breaks them. For budgets, we expected LiteLLM’s built-in settings to do the job, and planned our own check only if they didn’t. For cloud models, we chose a rule enforced before every change over trusting reviewers to spot a risky line.
What we built
Closing the side doors
The stock-analysis experiment and the Telegram agent got their own keys, we switched off the chat UI’s direct Ollama backend, and each knowledge base became an ordinary model behind the gateway. Then we bound every service that only other containers need to the host’s loopback interface, the address only the machine itself can reach. Everything except the gateway now listens on the server itself, the gateway and dashboards need authentication, and Ollama’s own access log is a second witness for anything that slips past.
One key per client
| Kind | Alias pattern |
|---|---|
| a service on the server | the service’s name: the chat UI, n8n, the router, each knowledge-base worker, the Telegram agent, the stock-analysis tool |
| a lab device | named after the device |
| our own tests and proofs | a dedicated test key |
Model lists are opt-in, so the riskiest model, by then in its own sandbox, is only available to keys that ask for it. Interactive keys get a lower requests-per-minute ceiling than batch tools, so one runaway loop can’t fill the queue. We removed every copy and moved each client to its own key. The admin key is used by LiteLLM and admin commands only.
AI coding agents are treated like a new contractor: scoped, unprivileged access, no secrets in any file they can read, and every change through the repository and review. The live gateway configuration is read-only between deploys (Part 6).
A budget check at the gate
After the freezes described in Part 3, each model gets a 32,768-token context window per request, shared by the prompt and the answer. At that limit Ollama doesn’t refuse; it truncates silently, and an agent loses part of its own reasoning without knowing.
We built an admission check: a small pre-call hook in the gateway that counts each request against the model’s budget and refuses anything over it with an OpenAI-style 400 context_length_exceeded error, naming the count, the budget and what to do. The input budgets are 26,000 tokens for the four 32k models and the auto router, leaving at least 6,768 tokens for the reply, and 13,000 for the two models with a 16k window. Later, refusing became compacting at the gateway (Part 4).
Queueing and tracing
Each heavy model has max_parallel_requests: 2. In the LiteLLM version we ran, a request over that cap waited for a free slot; current releases reject it at once with a 429 instead. Setting it to 1 would only have moved the queue from Ollama into LiteLLM, so we made queueing visible instead (Part 7). Every request leaves a row in LiteLLM’s spend log and a trace in Langfuse, an open-source tool for observing LLM calls.
No cloud fallback, and one narrow exception
LiteLLM fallbacks retry a failed or slow request on another model. We use them only between the two resident local models, gemma4 and qwen3-coder. A pre-commit egress check refuses:
- any fallback (ordinary, context-window or content-policy) from a local model to a cloud model;
- any
default_fallbacksentry that names a cloud model, because a default applies to local models too; - any OpenRouter deployment whose model id doesn’t end in
:free; - any fallback naming a model that doesn’t exist, because a typo would silently switch off the first rule.
The one exception covers public data only. TradingAgents, the stock-analysis experiment (Part 8), makes about 30 local analyst calls per run plus two "deep" calls, and those two may use a model on OpenRouter’s free tier, only for the lab’s public-market test data. Free tiers may log or train on prompts, so nothing else goes to them.
Four controls keep that exception narrow:
- the cloud models are granted to the stock-analysis key only;
- the OpenRouter key has a $0 credit limit;
- each sets
allow_fallbacks: false, so OpenRouter can’t re-route to another provider of the same model (it doesn’t stop a model fallback configured elsewhere); - the only fallback goes from each free model to local
qwen3-coder, with prompts compacted to the local budget first, and every call leaves a log line saying where it ran.
Thinking about private AI for your organisation? We design, build and secure on-premises AI platforms for Australian organisations, with the guardrails this series describes built in from the start.
Challenges we hit
The built-in budget setting doesn’t enforce anything
We assumed LiteLLM’s per-model max_input_tokens would stop oversized prompts. By default it is only model metadata. LiteLLM enforces it only when pre-call checks are on (enable_pre_call_checks), and then it filters deployments rather than telling the caller what went wrong. That is why we wrote our own hook. Even then, a budget in the wrong place did nothing: the hook reads model_info, and a value under litellm_params looks right but is ignored. A pre-commit check now refuses a local model without a declared budget, and the guard re-checks the running gateway.
The token counter was wrong by 322 times
The gate had been letting through prompts it should have refused, because it never counted tool definitions. A short message carrying a dozen tool schemas counted as 11 tokens but cost 3,540. The counter also grouped digits three to a token, while these models spend one token per digit. Of 76 samples, 59 were unsafe before the fix.
Traces that never arrived
The Langfuse callbacks had sat under the wrong configuration section since the first build, and LiteLLM ignores them there without complaint, so no traces had ever been recorded. Moved to the right section, every trace batch crashed the Langfuse web container. Langfuse v3 needs Postgres, ClickHouse, Redis, S3-compatible blob storage and a worker, and ours had been installed without the last three. It restarted 72 times before we took the callbacks out and completed the stack.
Keys leak in quiet ways
In LiteLLM an empty model list means every model, cloud ones included, which is how a dashboard key quietly gained access to everything. Any key with every-model access must now be listed in a file in the repository, so the commit is the approval. A load test once fell back to the admin key and raised "admin key used", the one alert that must never be noise, so tests now use a dedicated key limited to local models with a $1 per 30 days tripwire. Open WebUI stores its key in its environment and its database, and the database copy wins at start-up.
A fallback can send a document to the cloud
Picture a document-reading model with a short timeout and a cloud fallback. Whenever the single local slot stays busy past the timeout, LiteLLM re-sends the request, meaning the document itself, to the cloud provider, and the caller can’t tell. A fallback belongs to a model, not a key, so it applies to every caller. The lab’s vision model has no cloud fallback and no timeout; a busy slot means the call waits.
The result
In the first test of the gate, a 45,183-token request was refused in milliseconds and never reached the GPU. After the counting fix, none of the 76 samples was unsafe. Traces now arrive in Langfuse, and a soak test ran with no restarts. From another lab machine the database and model ports were refused while the gateway answered. A scheduled guard compares gateway and Ollama logs, alerts on admin-key use, flags unapproved every-model keys and counts refusals per key. We added that last check after the Telegram agent had 20 requests refused over five days and nothing said so (Part 9).
One door has costs. The gateway is a single point of failure: a configuration change means a restart of about 10 seconds, or about 30 for a recreate. Budgets sit below the model card. With one slot per model, a colliding caller waited a median of 18 seconds over seven days of test traffic, with 90 per cent of waits under 58 seconds. Every new client needs a key and a model list, on purpose. And alerts are not enforcement: scoped access, read-only configuration and pre-commit checks are what prevent problems.
Do it yourself: put LiteLLM in front of Ollama
What you need: the server from Part 1 with Ollama on loopback, Python 3, Postgres (a distro package or the official container), and about two hours.
Prepare the database and secrets. Create a service account, a Postgres role and a database, then an environment file only that account can read. LiteLLM’s admin key, the master key, must start with
sk-.sudo useradd --system --home /opt/litellm litellm sudo -u postgres createuser -P litellm && sudo -u postgres createdb -O litellm litellm sudo install -d -m 750 -o litellm /etc/litellm printf '%s=%s\n%s=%s\n' LITELLM_MASTER_KEY "sk-$(openssl rand -hex 32)" \ DATABASE_URL 'postgresql://litellm:<db-password>@127.0.0.1:5432/litellm' \ | sudo install -m 600 -o litellm /dev/stdin /etc/litellm/litellm.envCheck:
sudo -u postgres psql -lqt | grep litellmlists the database, andsudo ls -l /etc/litellm/litellm.envshows-rw-------.Install a pinned proxy in its own virtual environment. The
extra_proxyextra brings the Prisma client the proxy needs once a database is configured.sudo python3 -m venv /opt/litellm sudo /opt/litellm/bin/pip install 'litellm[proxy,extra_proxy]==<version>'Check:
/opt/litellm/bin/pip show litellm prismaprints both, with the version you pinned. Read the release notes before any upgrade, because pip doesn’t run database migrations for you.Write
/etc/litellm/config.yaml. Useollama_chat/so tool calls work, and set the budget inmodel_info.model_list: - model_name: qwen3-coder litellm_params: model: ollama_chat/qwen3-coder-32k api_base: http://127.0.0.1:11434 max_parallel_requests: 2 model_info: max_input_tokens: 26000 router_settings: enable_pre_call_checks: true general_settings: master_key: os.environ/LITELLM_MASTER_KEY database_url: os.environ/DATABASE_URLAdd no
fallbacksordefault_fallbacksthat point at a cloud model. On current releases a request overmax_parallel_requestsgets an immediate429, so pin the version you tested, raise the cap and let Ollama queue, or handle429in clients. For a quick start,enable_pre_call_checksfilters out models too small for a request. For a clear error and a count that includes tool definitions, write the hook in Part 4’s steps and register it underlitellm_settings: callbacks. Check: the file is committed to Git.Run the gateway as a systemd service under the unprivileged account, bound to loopback.
[Service] User=litellm EnvironmentFile=/etc/litellm/litellm.env ExecStart=/opt/litellm/bin/litellm --config /etc/litellm/config.yaml --host 127.0.0.1 --port 4000 Restart=on-failureSave it as
/etc/systemd/system/litellm.servicewith[Install]WantedBy=multi-user.target, thensudo systemctl enable --now litellm. Put a TLS reverse proxy in front for other machines, or firewall port 4000 to known hosts; keys are bearer tokens, so never send them over plain HTTP. Check:curl -s localhost:4000/health/livelinessanswers that it is alive.Mint one key per client, on the server itself, so the master key never crosses the network. Load it first with
set -a; . /etc/litellm/litellm.env; set +ain a root shell.curl -s localhost:4000/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" \ -H 'Content-Type: application/json' \ -d '{"key_alias":"chat-ui","models":["qwen3-coder"],"rpm_limit":30}'Check: the JSON contains a
key. Store it only in that client’s configuration.Send a test request with the client key.
curl -s localhost:4000/v1/chat/completions -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"model":"qwen3-coder","messages":[{"role":"user","content":"Say hello."}]}'Check: you get an answer, the call appears in the spend log under
chat-ui, and asking for a model not on the key’s list returns an authorisation error.Test the budget. Send a prompt of around 40,000 words, generated with a short script. Check: you get an error, not an answer, and Ollama’s log (
journalctl -u ollama) shows no newPOST /api/chatline for it. Then send a short message with a dozen tool definitions. With only the built-in checks it will almost certainly get through, because LiteLLM’s counter ignores tool schemas; Part 4’s hook closes that gap.Refuse cloud fallbacks before commit. Add a pre-commit script that parses
config.yamland fails if any fallback leads from anollamamodel to another provider. Check: add a bad fallback on purpose; the commit is refused.Confirm only the gateway is reachable. From another machine, run
curl -m 5 http://<server>:11434, then request the gateway’s health path through your TLS proxy. Check: the first is refused or times out and the second answers.
Key takeaways
- One door makes control possible. Bind everything else to loopback and keep the model server’s log as a second witness.
- The admin key is for administration only, not even for your own tests.
- LiteLLM’s
max_input_tokensis metadata by default. Switch on pre-call checks or enforce it yourself, and count tool definitions. - Fallbacks belong to the model, not the key. Refuse local-to-cloud fallbacks in CI rather than hoping a review catches them.
- If you allow cloud models at all, keep them narrow: one key, public data only, no provider re-routing, a local fallback and a log line for every call.
Planning private AI?
Catharsis Cyber Security is a Sydney cyber security company. We design, build and secure private AI platforms like the one in this lab, and help you decide where local or cloud AI fits.
Keep reading the series





Why Does a Local AI Server Freeze? Sizing Memory for LLMs (Part 3 of 11)