A tiny proxy for safely exposing a self-hosted OpenAI-compatible LLM server to the internet. API keys with expiration, per-key limits on requests and tokens, lifetime token quotas, and metadata-only usage logs, behind a small dashboard.
One 11MB static binary, one SQLite file, nothing else.
More screenshots at quecto.jgulfo.com/proxy.
flowchart TD
C["Client<br/>OpenAI SDK, Open WebUI, n8n"]
E["TLS<br/>reverse proxy or tunnel"]
Q["Quecto :11312<br/>authenticate, rate limit<br/>count tokens, log metadata"]
M["Model server<br/>llama.cpp, vLLM, Ollama"]
A["Dashboard :11313<br/>same process, own port<br/>keys, usage, settings"]
C -->|"https, bearer qp_..."| E -->|http| Q -->|http| M
M -.->|"tokens counted on the way back"| Q
A -.- Q
git clone https://github.com/jesdga95/quecto-proxy && cd quecto-proxy
# Creates the admin account on first start, then ignored. Save it, you cannot read it back.
echo "QUECTO_ADMIN_PASSWORD=$(openssl rand -base64 24)" > .env && cat .env
docker compose up -d --build
docker compose logs quecto # expect: msg=listening addr=:11312Edit QUECTO_UPSTREAM_URL in docker-compose.yml if your model server is not on port 8080. On a stock Linux engine every docker command needs sudo; compose reads .env from the project directory, so the password survives it.
Open http://localhost:11313/admin, sign in, create a key, then point any OpenAI client at http://your-host:11312/v1:
curl -sS http://localhost:11312/v1/chat/completions \
-H "Authorization: Bearer qp_your_key_here" \
-H "Content-Type: application/json" \
-d '{"model":"local","messages":[{"role":"user","content":"hello"}]}'Without compose, docker build -t quecto . and pass the same variables with -e, publishing both ports. Write the password as -e VAR="$VAR"; the bare form reads it from Docker's own environment, which sudo clears. On Linux, --add-host host.docker.internal:host-gateway is what lets the container reach a model server on the host.
:11312, the API. The only port that needs to be reachable. It answers 404 for every/quecto/apiand/adminURL, so whatever you point at it carries the API and nothing else.GET /quecto/healthis its one unauthenticated route.:11313, the dashboard. Nothing rate limits password guesses at the login, so keep it on your own network or put an identity layer in front of it.
Everything outside /quecto belongs to the model server's own shape, so /v1 is forwarded whole and the upstream's root routes are forwarded by name.
/v1/... |
Forwarded whole. Whatever your model server serves under /v1 is what exists |
GET /health, GET /props, GET /models |
The upstream's own. What is loaded, and whether it is ready. /models is the server's own listing and answers differently from /v1/models |
POST /tokenize, /detokenize, /apply-template |
The upstream's own. Needs a key, counts against the per-minute request limit, bills no tokens |
GET /quecto/health |
Quecto's own liveness, no key. Says nothing about the model |
/quecto/api/... |
The dashboard's API, session authenticated |
The upstream's generating routes (/completion, /infill, /embedding, /reranking) are not forwarded: they report token counts in the model server's own shape rather than an OpenAI usage block, so Quecto cannot meter them. Neither are the routes that reach across callers, and those stay off for good: GET /slots returns the prompt sitting in each slot, and POST to /props or /lora-adapters changes the server underneath everyone else.
Quecto is built for one specific case: one self-hosted model server, exposed publicly, with per-key limits and usage accounting. Gateways built for fleets of cloud providers do far more, and require Postgres, Redis, and a few gigabytes per worker to do it. A reverse proxy in front gives you TLS and a shared password, and tells you nothing about who spent what. Quecto is the small thing in between: keys that expire, a cap on what each one spends, and a record of it that never stores prompts.
| Variable | Default | |
|---|---|---|
QUECTO_UPSTREAM_URL |
required | Your model server, with or without the /v1 suffix |
QUECTO_ADMIN_PASSWORD |
Creates the admin account on first start, then ignored | |
QUECTO_LISTEN |
:11312 |
qp in ASCII decimal, clear of llama.cpp on 8080 |
QUECTO_ADMIN_LISTEN |
:11313 |
The dashboard and its API. Set it to QUECTO_LISTEN to share one |
QUECTO_DB_PATH |
./quecto.db |
|
QUECTO_UPSTREAM_API_KEY |
Swapped in for the caller's key on the way out | |
QUECTO_LOG_RETENTION_DAYS |
30 |
Hourly totals outlive raw rows, so charts stay long range |
QUECTO_LOG_MAX_ROWS |
1000000 |
Cap on raw log rows, whichever of this and retention hits first |
QUECTO_DEFAULT_MAX_TOKENS |
2048 |
Reserved against token limits when a request names no max_tokens |
QUECTO_MAX_REQUEST_BODY_MIB |
256 |
Largest request body in MiB. Bigger ones get HTTP 413 |
QUECTO_SESSION_LIFETIME_DAYS |
14 |
How long a dashboard login lasts, 1 to 365. Applies to new logins |
QUECTO_UPSTREAM_HEADER_TIMEOUT |
120s |
How long to wait for the upstream to start replying |
QUECTO_TRUST_PROXY_HEADERS |
false |
Believe X-Forwarded-Proto and X-Forwarded-For. Only behind a proxy you control |
QUECTO_SESSION_SECRET |
generated | Signs dashboard sessions. Left unset, one is generated and kept in the database |
The upstream URL and key, default max tokens, log retention and log max rows are also editable from the dashboard, and a value saved there wins over the environment. The rest are read at startup. See internal/config.
- Token limits reserve on admission and settle on completion, since the cost is only knowable once a reply finishes. The per-minute bucket can go negative; clamping at zero would make oversized requests free.
- A spent lifetime quota is terminal,
401 exhausted_api_keyrather than a429that would trap an SDK in a backoff loop. A quota merely held by calls in flight answers429 quota_pendinginstead. - Streamed replies are still counted. Quecto adds
stream_optionsupstream and strips the usage frame back out, so callers see the same bytes as talking to the model directly. Responses carry OpenAI's ownX-RateLimit-*headers. - Logs are metadata only. Prompts and responses are never stored, and there is no setting to turn that on.
The container prints its reason and exits, so a start that vanishes from docker ps is visible in docker logs quecto.
| What you see | What it means |
|---|---|
no admin password is set |
QUECTO_ADMIN_PASSWORD never reached the container, see the -e note above |
QUECTO_UPSTREAM_URL is required |
Missing or empty, it has no default |
| 401 on a request | Key is wrong, expired, or revoked. The body says which |
| Connection error on a request | Quecto cannot reach the upstream. Check QUECTO_UPSTREAM_URL |
404 on :11312/admin |
The dashboard is on its own port. Publish it with -p 11313:11313 |
| 404 on a native route | Only the utility routes are forwarded, see the table above |
The password is only consumed when no admin account exists yet, so changing the variable later does nothing. Reset it from the dashboard instead.
make build # dashboard first, then the binary that embeds it
make test
make bench # roughly +50us per request, measured against a null upstreamThe dashboard is never committed; docker build always compiles it from source. More in CONTRIBUTING.md.
Quecto terminates no TLS and is meant to sit behind something that does. Threat model and known limitations: SECURITY.md.
MIT. Third-party components bundled into the binary and image, including the Poppins font (SIL OFL 1.1), are listed with their licenses in NOTICE.