This tutorial takes you from a fresh clone to one successful, OpenAI-shaped chat completion through the gateway, with a config file that passes -validate, a virtual key you generate yourself, and the curl and Python calls that show the request went through. It is for a developer or operator who has never run the gateway before and wants a single working path in about ten minutes. It follows one path only: build from source, route one model to OpenAI. Other ways to run the gateway are linked at the end.
You need:
- A Go toolchain that satisfies
gateway/go.mod(go 1.27.2). git,curl,openssl, andshasum(macOS) orsha256sum(Linux). The steps below useshasum -a 256; on Linux, substitutesha256sumin the same position.- An OpenAI API key. The gateway reads it from an environment variable at startup and never stores it.
- Python 3 with the OpenAI SDK, for Step 9 only:
python3 -m pip install openai. Skip Step 9 if you do not want Python; Steps 7 and 8 already prove the gateway works. - Port 8080 free on your machine.
Keep one shell open for the whole tutorial. The steps share four shell variables (KELVRAN_KEY, KELVRAN_KEY_HASH, OPENAI_API_KEY, GATEWAY_PID) and one background process.
The Go module lives in the gateway/ subdirectory of the repository, so the build runs from gateway/gateway after the clone.
git clone https://github.com/kelvran/gateway.git && cd gateway/gateway
go build -o /tmp/kelvran-gateway ./cmd/gateway
/tmp/kelvran-gateway -versionWhat you should see: one line of the form kelvran-gateway dev (none, built unknown, go1.27.2, darwin/arm64). A source build identifies itself as dev; the Go version and platform reflect your machine. The binary prints this without reading any config.
kelvran init [--single-user] (since gateway/v0.18.0; build it beside Step 1's binary with go build -o /tmp/kelvran ./cmd/kelvran) does Steps 2–4 for you: it generates the secret, writes config.yaml for the provider credential it finds in your shell, validates it and prints the client exports — paste only the KELVRAN_KEY line into this one shell, or run the client exports in a second shell, because the OPENAI_API_KEY="$KELVRAN_KEY" line would replace the provider key Step 5 exports. See the CLI reference. The manual path below stays valid; it is the documented key_hash contract.
A virtual key is a bearer secret the gateway issues to a client. The config file stores only the SHA-256 hash of the secret, never the secret. printf '%s' matters: the gateway hashes the exact bytes the client sends, with no trailing newline.
export KELVRAN_KEY=$(openssl rand -hex 32)
export KELVRAN_KEY_HASH=$(printf '%s' "$KELVRAN_KEY" | shasum -a 256 | cut -d' ' -f1)
echo "$KELVRAN_KEY_HASH"What you should see: a 64-character lowercase hex string. That is the key_hash for the next step. $KELVRAN_KEY is the secret your client will send as Authorization: Bearer <secret>.
Write the config into the current directory. config.yaml is the binary's default -config path, and the repository's .gitignore already excludes a file by that name, so it cannot be committed by accident. The heredoc fills in the hash from Step 2.
cat > config.yaml <<EOF
listen_addr: ":8080"
virtual_keys:
quickstart:
key_hash: "${KELVRAN_KEY_HASH}"
budget_usd: 25.0
deployments:
gpt4o-primary:
model: "gpt-4o"
provider: "openai"
upstream_model: "gpt-4o"
base_url: "https://api.openai.com/v1/chat/completions"
api_key_env: "OPENAI_API_KEY"
price_table:
gpt-4o:
prompt_per_token: 0.0000025
completion_per_token: 0.00001
telemetry:
exporter: "none"
EOF
cat config.yamlWhat you should see: the YAML above with your hash inside the key_hash quotes.
What each block does:
listen_addris the only required top-level scalar.virtual_keysneeds at least one entry with a non-emptykey_hash.quickstartis the key's name;budget_usdcaps what this key may spend.deploymentsneeds at least one entry withmodel,provider,upstream_modelandbase_url.modelis the name clients send;upstream_modelis what the provider receives. A non-bedrock deployment also needsapi_key_env, the NAME of the environment variable that holds the provider key.base_urlmust behttps://.price_tablepricesgpt-4oso the budget can count. A model absent fromprice_tablecosts 0 USD, and a budget never decrements for it.telemetry.exporter: "none"keeps stdout to log lines only. Without this section the gateway prints OTel spans and metrics as JSON on stdout as well.
The parser is a small YAML subset: key: value scalars and 2-space-indented mappings. A tab in indentation or a duplicate key at one level is a load error. Every key is annotated in gateway/config.example.yaml; the full key list is in the config reference.
/tmp/kelvran-gateway -validate -config config.yamlWhat you should see: config is valid and exit status 0. On a problem it prints config error: ... to stderr and exits 1.
-validate only loads the file and checks its shape: required fields, that every provider is one of openai, anthropic, gemini, bedrock, openaicompat, and that fallback_chains targets exist. Beyond reading the config file itself, it never reads an environment variable, never opens a credential file or a store, and never dials anything. It also does not check that key_hash is well-formed hex; a malformed hash passes -validate and fails at real startup with constructing identity verifier.
Export the provider key, then start the gateway in the background with its log going to a file so this shell stays usable.
export OPENAI_API_KEY="<your OpenAI API key>"
/tmp/kelvran-gateway -config config.yaml > /tmp/kelvran-gateway.log 2>&1 &
GATEWAY_PID=$!
sleep 1
cat /tmp/kelvran-gateway.logWhat you should see on a Linux host: three JSON log lines, in this order.
{"time":"...","level":"INFO","msg":"build_info","version":"dev","commit":"none","date":"unknown","go_version":"go1.27.2","platform":"linux/amd64"}
{"time":"...","level":"INFO","msg":"gateway_starting","instance_id":"..."}
{"time":"...","level":"INFO","msg":"gateway listening","addr":":8080"}
On macOS, Windows or any other host without cgroups, two more lines appear between build_info and gateway_starting: {"time":"...","level":"ERROR","msg":"failed to set GOMEMLIMIT","package":"github.com/KimMachineGun/automemlimit/memlimit","error":"failed to set GOMEMLIMIT: cgroups is not supported on this system"} and {"time":"...","level":"WARN","msg":"automemlimit: could not set GOMEMLIMIT from cgroup","error":"failed to set GOMEMLIMIT: cgroups is not supported on this system"}. Both are harmless: the gateway only reads a cgroup memory limit on Linux, and it is up once the last line is gateway listening. Set AUTOMEMLIMIT=off before starting to replace them with one INFO line AUTOMEMLIMIT is off, skipping. Inside a Linux container that has a memory limit, one INFO line GOMEMLIMIT is updated appears in the same position instead.
gateway listening means the data plane is accepting connections. If you forgot the export, you also see a WARN line deployment's upstream API key env var is not set; calls to this deployment will fail. The gateway still starts in that case; Step 7 then fails with a 502.
curl -s http://localhost:8080/healthz
echo
curl -s http://localhost:8080/readyz
echoWhat you should see:
{"status":"ok"}
{"models":{"gpt-4o":true},"ready":true}
Neither route needs a bearer token. /healthz is pure liveness and always answers 200 while the process is up. /readyz answers 200 when every configured model has a healthy deployment and 503 otherwise; with no health_probe section, every deployment stays eligible, so it reports ready.
curl -s -i http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer $KELVRAN_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"Say hello in five words."}]}'What you should see: status HTTP/1.1 200 OK, Content-Type: application/json, an X-Kelvran-Overhead-Duration-Ms header (the gateway's own added latency in milliseconds, excluding the provider's time), and a body of this shape:
{"id":"...","object":"chat.completion","created":1759900000,"model":"...","choices":[{"index":0,"message":{"role":"assistant","content":"..."},"finish_reason":"stop"}],"usage":{"prompt_tokens":14,"completion_tokens":7,"total_tokens":21}}The gateway hashed the bearer token, matched it to quickstart, routed gpt-4o to the gpt4o-primary deployment, sent the request upstream with your OPENAI_API_KEY, and priced the usage against price_table toward the key's 25 USD budget.
The request body is OpenAI Chat Completions shaped: model, messages[] of {role, content}, and optional temperature, max_tokens, top_p, stop, tools, tool_choice, stream, response_format. Unknown fields are dropped silently.
Send the same request with a wrong token.
curl -s -i http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer wrong" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}'What you should see: HTTP/1.1 401 Unauthorized, Content-Type: application/json; charset=utf-8, X-Content-Type-Options: nosniff, and this body:
{"error":{"message":"dataplane: auth: identity: invalid virtual key","type":"authentication_error","param":null,"code":"invalid_api_key"}}Every error on the three /v1 routes uses this OpenAI envelope with all four keys present (null when unknown), so the official OpenAI SDKs raise their usual exception classes. The full status-to-code table is in the error codes reference.
Any OpenAI SDK works by pointing base_url at the gateway's /v1 prefix and passing the virtual key secret as the API key. With the openai package installed (see Before you start), in the same shell:
python3 - <<'EOF'
import os
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8080/v1", api_key=os.environ["KELVRAN_KEY"])
resp = client.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": "hi"}])
print(resp.choices[0].message.content)
EOFWhat you should see: one line with the model's reply. The SDK sent Authorization: Bearer $KELVRAN_KEY and parsed the same chat.completion body as Step 7. More SDK detail is in the OpenAI Python how-to.
kill "$GATEWAY_PID"
sleep 1
tail -n 2 /tmp/kelvran-gateway.logWhat you should see: a log line with "msg":"gateway shutting down", then the process exits. SIGTERM and SIGINT both trigger a graceful shutdown: up to 30 s to drain the HTTP server, then up to 15 s for in-flight handlers, then up to 5 s to flush telemetry. With no traffic it exits at once.
You now have a built binary, a config that loads, a virtual key with a budget, and a client that talks to the gateway exactly as it would talk to OpenAI.
502witherror.messageupstream provider returned status 401anderror.codeupstream_error: your virtual key was accepted and the request reached OpenAI, which rejectedOPENAI_API_KEY. Check the export in Step 5. The provider's full error text is only in the gateway'schat_completionlog line, never in the response.config error: ... is not https: abase_urlwithhttp://needsallow_insecure_http: trueon that deployment. This tutorial'sbase_urlishttps://and does not need it.gateway exitedwithconstructing identity verifier: thekey_hashis not 64 hex characters. Re-run Step 2 and Step 3.
The full catalogue is in the troubleshooting how-to and FAILURE-MODES.
- No downloadable binaries or packages for
gateway/v0.17.0or earlier tags: release assets exist sincegateway/v0.18.0, and the older tags have no assets and cannot be rebuilt. See RELEASE.md. - No Homebrew formula, no npm or crates.io package.
- No
make runormake devtarget;make setuponly downloads dependencies. - No first-party client SDK. The OpenAI SDK with
base_urlis the integration path, as in Step 9. - No hosted copy of the OpenAPI document for the public routes; the committed one is
gateway/openapi/kelvran-gateway.openapi.yaml(reference). - Exact token counts only on an
anthropicdeployment:POST /v1/messages/count_tokensrelays the deployment's own count there and answers404on every other (Claude Code falls back to a character-based estimate). The gateway serves eight routes:POST /v1/chat/completions,POST /v1/embeddings,GET /v1/models,POST /v1/messages(the Anthropic Messages shape),POST /v1/messages/count_tokens,GET /healthz,GET /readyz,HEAD /api/hello.
- Your first virtual key and budget: rate limits,
allowed_models, budget windows and what a 429 looks like. - Streaming: add
"stream": trueand read Server-Sent Events ending indata: [DONE]. - Provider credentials: Anthropic, Gemini, Bedrock and self-hosted
openaicompatdeployments. - Run with Docker Compose and the container image reference: the same config mounted at
/config.yamlinghcr.io/kelvran/gateway. - Data-plane API reference: every route, header and response field.
- Your first eval suite: the separate Python
evalsdeployable. - Security model: why the config holds hashes and environment-variable names, never secrets.