Skip to main content
Most AI apps start by calling a model API directly. That works well for prototypes, but once multiple apps, services, or customers need access, direct provider calls get harder to manage. Every service needs a provider key, every client needs to learn provider-specific behaviour, and every team ends up solving authentication, limits, and observability slightly differently. An LLM gateway gives us one place to authenticate callers, enforce rate limits, hide upstream provider keys, record telemetry, and keep a stable API for our own applications. In this tutorial, we’ll build one in Rust with Axum, Postgres, SQLx, and the Venice AI API. By the end, you’ll have a gateway that exposes an OpenAI-compatible /v1/chat/completions endpoint, accepts your own bearer tokens, forwards requests to Venice, supports streaming responses, and emits useful OpenTelemetry spans and metrics. Interested in the full code implementation? Check out the GitHub repo.

Pre-requisites

  • Rust 1.92+
  • Docker and Docker Compose
  • A Venice API key
  • curl
  • Basic familiarity with Rust web services
Before we start, export your Venice API key:
We will never expose this key to client apps. The gateway will hold it server-side and clients will authenticate with gateway-specific API keys instead.

What We’re Building

The reference implementation is a small Rust service with a few clear parts: Architecture diagram showing a client calling the Rust gateway, Postgres, Venice AI, and OpenTelemetry A client sends an OpenAI-compatible request to the gateway. The gateway authenticates the caller, checks rate limits, forwards the request to Venice, and records telemetry along the way. As part of the gateway, we’ll be ensuring that this service remains horizontally scalable with the least amount of surface area covered when it comes to the API itself. There’s a few reasons for this - one of them primarily being that if you have a very high amount of throughput for example, you will almost certainly want to use replicas (ie, spin up more than 1 of the same service). This means that if you aren’t already, architecturally you’ll want to put your original service and the replicas behind a load balancer so that if one container or service goes down, the entire service does not experience an outage. Additionally, we will also assume that we own API key creation in some manner, although the gateway service shouldn’t be minting them in isolation. This will be represented as a Postgres table that we seed when used locally. In production, this would typically be handled by the authentication service. Although it is possible to handle creating an API key upstream for every user who uses your LLM gateway, in practice this is not generally advisable. By offloading this responsibility to the upstream service, you also offload any control you would normally have - which means you can’t fully enforce things like rate limiting and spend caps. The source tree stays intentionally small:
Without any further ado, let’s get building.

Creating the Rust service

Start with a new Rust binary project:
Add the dependencies we need in Cargo.toml - explanations added in the code snippet:

Loading configuration

The gateway reads everything from environment variables. For infrastructure code like this, environment variables are a good default because the same binary can run locally, in Docker Compose, or in a hosted environment without a separate config file format. Additionally, many providers will allow you to store your own environment variables as secrets in their own container runtime. This is often much safer than trying to use something like dotenv (or dotenvy in Rust, as the original dotenv crate is mostly deprecated). Create src/config.rs:
While there are a lot of possible values being parsed here from environment variables, generally speaking you only need two:
  • The database URL
  • Your Venice API key
Two defaults matter here. VENICE_BASE_URL points at https://api.venice.ai/api/v1, and CAPTURE_GENAI_CONTENT defaults to false so prompt content is not logged unless you intentionally enable it. That second default is the more important one. A gateway can see every prompt and response flowing through it, but observability should not automatically become content capture. In most production systems, token counts, latency, model names, status codes, and billing metadata are enough for operations. Generally speaking, logging prompts and conversations in production may not only be a privacy liability - it can also be a storage liability. Adding them means creating spans and traces with an extremely high level of cardinality (ie, the uniqueness of data within a dataset). This can make searching through your observability data very inexpensive, in addition to potentially hampering performance when searching through the data.

Creating the database schema

Next, create migrations/0001_api_keys.sql. We will store only the first 12 characters of each gateway API key as a lookup prefix, plus the SHA-256 hash of the full key. That lets the gateway find a candidate row quickly without storing raw credentials. The prefix is not secret. It exists for indexing. The hash is what proves that the caller presented the full key. This is the same basic shape used by many API-key systems: show the raw key once, store a non-reversible representation, and keep a short prefix around for lookup and support workflows.
Now add a table for fixed-window rate limiting:
This schema is small, but it gives us the important invariants:
  • API keys are never stored in plaintext.
  • Rate limit settings must be positive.
  • Revoked keys cannot remain active.
  • A rate limit window is uniquely identified by key, start time, and window length.
Keeping those invariants in Postgres is useful because every caller has to pass through this database state. Even if we later add an admin API, a background key-rotation job, or a migration that imports keys from another system, the database still rejects impossible states like an active key with a revocation timestamp.

Building the Venice client

Next, we will create src/venice.rs. The client only needs to know the upstream chat completions URL, the Venice API key, and how many times to retry transient failures. Keeping this wrapper small is intentional - the gateway should not reimplement Venice’s entire API. At a basic level, the gateway’s job is to attach the server-side credential, apply a timeout, retry requests that are safe to retry, and return the upstream response in a form the router can forward.
For non-streaming requests, we can retry connection errors, timeouts, and transient HTTP status codes:
Retries are only applied to the non-streaming path. Once a streaming response has started, retrying inside the gateway could duplicate partial output or confuse clients that already received chunks. For streaming, the better default is to surface the error and let the caller decide whether to retry the whole request. For streaming, we create an EventSource from the same request:
Venice’s chat completions endpoint is OpenAI-compatible, so the gateway can accept a familiar body:
You can swap the model for any chat-capable model available to your Venice account. Notice that the request body is still a serde_json::Value. That is a deliberate compatibility choice. If we model every possible chat-completion field in Rust, we have to keep the gateway updated every time the upstream API adds a useful option. By parsing only what we need elsewhere, we let newer Venice parameters pass through without a gateway release.

Sharing application state

Create src/state.rs:
Axum clones state into handlers, so the state itself should be cheap to clone. PgPool is already a shared pool handle, and Arc<Config> keeps configuration cheap as well. This gives every handler access to the same three things: immutable configuration, pooled database connections, and the Venice client. Keeping them in one AppState also makes testing simpler later because handlers receive their dependencies through Axum state instead of reading globals.

Authenticating gateway API keys

The client sends its gateway key like this:
Create src/auth.rs and implement an Axum extractor. The extractor lets protected handlers declare that they require an authenticated key:
The actual authentication flow is:
  1. Parse the bearer token.
  2. Take the first 12 bytes as the key prefix.
  3. Hash the full candidate token with SHA-256.
  4. Load the active key row by prefix.
  5. Compare the stored hash and candidate hash in constant time.
This keeps upstream and gateway credentials separate. Your production apps can rotate gateway keys without changing the Venice API key, and the Venice key never needs to leave the server. The extractor pattern is helpful because authentication becomes part of the handler’s type signature. A route that accepts AuthenticatedApiKey cannot accidentally skip auth inside the function body; Axum has to construct that value before the handler runs. That keeps the protected path easy to audit.

Adding fixed-window rate limits

Create src/rate_limit.rs. The rate limiter uses one SQL statement to insert a new window or increment the existing one:
The WHERE api_key_rate_limit_windows.request_count < $3 clause is the important bit. When the window is already full, Postgres does not update the row and RETURNING produces no row. The handler can turn that into a 429 Too Many Requests response with a Retry-After header. A fixed window is not the most sophisticated rate limiter, but it is easy to explain, easy to inspect, and good enough for a gateway tutorial. The tradeoff is that traffic can bunch up around window boundaries. If you need smoother behaviour at scale, a token bucket or sliding-window limiter backed by Redis is a natural next step.

Returning OpenAI-style errors

Create src/error.rs and make application errors implement IntoResponse:
For gateway-generated errors, return a JSON body shaped like common model API errors:
For upstream Venice errors, preserve the upstream status code and body. That makes debugging much easier for clients because provider-level validation errors still look like provider-level validation errors. This split keeps the gateway honest about where an error came from. If the gateway rejects a request because the bearer token is missing or the caller is over limit, it returns a gateway-shaped error. If Venice rejects the model request, we preserve the upstream body so client developers can see the provider’s validation message instead of a generic proxy failure.

Building the router

Now we can wire the HTTP routes in src/router.rs:
The chat handler starts by requiring an AuthenticatedApiKey. If authentication fails, Axum never enters the handler body:
The gateway validates only the fields it needs for gateway behavior: model, messages, and stream. Everything else in the JSON body passes through to Venice. That keeps the gateway compatible with provider features you may want to use later. The handler also makes the two response modes explicit. Non-streaming requests wait for Venice to return a full JSON response, then record response metadata before sending the bytes downstream. Streaming requests return immediately with a text/event-stream body backed by an async stream. That split keeps the non-streaming path simple while giving the streaming path enough control to observe chunks as they pass through.

Supporting streaming responses

Streaming chat completions use server-sent events. Venice sends SSE data, and the gateway relays that data back to the client. The gateway should avoid buffering the whole stream because that would defeat the purpose of streaming. Users care about the time to first token, not only the time to final token. By forwarding each upstream event as it arrives, clients can render partial output while the model is still generating. Create src/sse.rs:
Each message is encoded back into SSE format:
This preserves the client experience that OpenAI-compatible SDKs expect: chunks arrive as data: ... events, and the stream ends with data: [DONE]. The stream observer is also where we can collect metadata without changing what the client sees. Each chunk is forwarded in SSE format, but the gateway can still watch for response IDs, finish reasons, token usage, cost fields, and timing information as those chunks pass through.

Recording GenAI telemetry

Gateways are useful because every request passes through one place. That makes them a great spot to record model, latency, token usage, finish reasons, billing cost, and streaming timings. Create src/telemetry.rs and start by parsing the request:
Then create a span using GenAI semantic attributes:
When a non-streaming response comes back, deserialize the known response metadata fields into structs. The gateway still forwards the original bytes to the client, but telemetry does not need to walk arbitrary JSON. The billing log uses the gateway key UUID rather than the plaintext bearer token, and the request ID comes from Venice’s response id:
For streaming responses, record time to first chunk and time between output chunks as the SSE stream is relayed. These metrics are especially useful when you care about perceived latency, not just total request time. Telemetry is where a gateway becomes more than a proxy. Once spans include the requested model, upstream model, token counts, finish reasons, status, and per-key billing logs, you can answer practical operational questions: which clients are spending the most, which models are slowest, whether streaming is improving perceived latency, and whether errors are coming from auth, rate limits, transport, or the model provider.

Starting the server

Now wire everything together in src/main.rs:
On startup, the gateway:
  1. Reads configuration.
  2. Initializes telemetry.
  3. Connects to Postgres.
  4. Runs SQLx migrations.
  5. Builds shared app state.
  6. Starts the Axum server.
Running migrations on startup is convenient for this tutorial because docker compose up can bring the whole stack to a working state. In a larger production deployment, you may prefer to run migrations as a separate release step so schema changes are reviewed and applied before new gateway instances start.

Seeding a local gateway key

For local development, create scripts/seed_api_key.sh. The script inserts a gateway API key into Postgres by storing its prefix and SHA-256 hash:
The default local key is:
For a real deployment, generate longer random keys, show them once to the caller, and store only the hash. The seed script is intentionally boring because local credentials should be easy to recreate. The production version is where you would add stronger key generation, audit logging, expiration, and a one-time display flow.

Running locally

To run locally, we’ll use Docker Compose to start both the gateway and Postgres. This keeps the tutorial reproducible: readers do not need a manually configured database, and the gateway can use the same DATABASE_URL shape it would use in a containerized deployment.
We’ll also need a small Dockerfile that builds the Rust binary and copies it into a smaller runtime image:
To run the stack, use the following command:
Don’t forget you can also run this detached with the -d flag if you want to use your terminal for other things after (and then use docker compose down to remove it). In another terminal, seed the development gateway key:
If your machine already has Postgres running on port 5432, remove the host port mapping for the Compose Postgres service. The gateway only needs to reach Postgres on Docker’s internal network. The important thing to keep in mind is that the Venice API key belongs only in the gateway environment. Client requests should use the seeded gateway key. That separation is the whole point of putting a gateway in front of the model provider.

Testing the gateway

First, check health:
You should see:
Now send a non-streaming chat completion request:
The response should look like an OpenAI-compatible chat completion:
For streaming:
You should see SSE chunks:
The repo also includes a smoke test script:
For local code quality checks, run:
Testing both response modes matters because they exercise different proxy paths. The non-streaming test proves that auth, rate limiting, upstream forwarding, and JSON response telemetry work. The streaming test proves that the gateway can keep an SSE connection open and forward chunks without buffering the final answer first.

Extending this gateway

This gateway is intentionally small, but it gives you a solid foundation. Good next steps include:
  • Add per-subject budgets and monthly spend limits.
  • Support multiple upstream providers behind the same OpenAI-compatible API.
  • Store request metadata for audit logs while keeping prompt logging disabled by default.
  • Add an admin API for creating, revoking, and rotating gateway keys.
  • Add model allowlists per API key.
  • Add Redis or another shared store if you need lower-latency rate limiting across many gateway instances.
The main design idea is to keep policy in the gateway and inference in Venice. That lets client apps use a familiar API while your platform keeps control over keys, usage, limits, and observability.

Finishing up

Thanks for reading! Hopefully this helped you see how to build a practical LLM gateway in Rust without turning it into a huge platform project. By combining Axum, Postgres, SQLx, OpenTelemetry, and Venice’s OpenAI-compatible chat completions API, we can build a gateway that is small enough to understand and useful enough to sit in front of real applications.