FastAPI шлюз пред Ollama: ключ и лимит
Ollama няма собствена защита: който стигне до порта му, може да ползва машината ти. Затова пред него слагаме малък шлюз с FastAPI — проверява ключ, пази лимит на заявките, предава отговора на потоци (дума по дума) и е единственият път към модела. Моделът остава на машината, а към мрежата се вижда само обратният прокси с TLS.
allow_origins=["*"]), стария формат /api/generate с prompt и context, големия модел llama3.1:70b, WebSocket (slowapi не го поддържа; Ollama така или иначе връща редове JSON по HTTP), отговора на проверката за здраве, който издава името на машината, стека с база за история на разговорите, и задачата за връзка с конкретно външно приложение. Добавихме: закачени версии (FastAPI 0.142.2, slowapi 0.1.10, Ollama 0.35.0), ключ в заглавка с сравнение за постоянно време, лимит на клиент, списък на разрешените модели и проверка на тялото, предаване на потока през /api/chat, ясни кодове за грешки, Ollama и шлюз само на 127.0.0.1 и обратен прокси с TLS и втори лимит. Примерите са измислени.
/api/chat) — не срещу истински модел и не на машина от класа GB10. Затова няма етикет „ТЕСТВАНО“. Не сме пускали: конфигурацията на nginx, TLS сертификатите, работата с истински модел и скоростта му, няколко работни процеса и Python 3.12 от DGX OS. Библиотеката slowapi сама се определя като „alpha quality“ — следи изданията ѝ.01Какво ще научиш
- Защо Ollama не се отваря към мрежата и какво слагаме пред него.
- Как да защитиш входа с ключ в заглавка и кога е нужно OAuth2 вместо ключ.
- Как да сложиш лимит на заявките със slowapi и къде той не стига.
- Как да предаваш отговора на модела на потоци, без да чакаш края.
- Как да пуснеш обратен прокси с TLS пред шлюза и да провериш, че нищо друго не слуша.
02Преди да започнеш
- Машина от класа NVIDIA GB10 (например ASUS Ascent GX10 или DGX Spark) с DGX OS (Ubuntu 24.04 за Arm64).
- Ollama, инсталиран като услуга на машината, с изтеглен модел (в примера
llama3.1). Нужна е версия 0.35.0 или по-нова. - Достъп до терминала — директно или по SSH, и Python 3.10 или по-нов (FastAPI 0.142 изисква 3.10+).
- Основи на HTTP и
curl. Домейн и сертификат — само за последната стъпка (TLS).
| Какво | Стойност (проверено на 01.10.2026) |
|---|---|
| FastAPI | 0.142.2 (PyPI, 30.09.2026), Python 3.10+ |
| slowapi | 0.1.10 (PyPI, 13.06.2026) — обвивка над библиотеката limits |
| httpx · uvicorn | 0.28.1 · 0.54.0 |
| Ollama | 0.35.0 (GitHub, 28.09.2026) |
| nginx | стабилна 1.30.5, основна 1.31.6 (nginx.org) |
api.example.com и пътищата са плейсхолдъри. Ключове си генерираш сам — никога не копирай чужд.03Стъпки
-
Какво ще стои къде
Път на една заявка: клиент → HTTPS към обратния прокси → шлюз (FastAPI) → Ollama. Шлюзът и Ollama слушат само на
127.0.0.1, значи отвън не се виждат. Защо шлюз? Ollama няма вход с парола; шлюзът добавя ключ, лимит, проверка на тялото и записи, без да пипаш самия модел.Част Задача Слуша на nginx TLS, първи лимит, пренасочване публичния порт 443 FastAPI шлюз ключ, лимит на клиент, проверка, поток 127.0.0.1:8000Ollama моделът 127.0.0.1:11434 -
Провери Ollama и портовете
bash · на машинатаollama --version ss -ltn | grep 11434Очакваш
0.35.0и ред с127.0.0.1:11434. Ако виждаш0.0.0.0:11434или*:11434, някой е сменил настройката — върни я, преди да продължиш. Не си слагайOLLAMA_HOST=0.0.0.0: така моделът става достъпен за цялата мрежа, и то без парола. -
Папка, среда и пакети със закачени версии
bashmkdir -p ~/ollama-gateway && cd ~/ollama-gateway python3 -m venv .venv . .venv/bin/activate pip install fastapi==0.142.2 slowapi==0.1.10 httpx==0.28.1 uvicorn==0.54.0Защо закачени версии? Без тях следващото инсталиране може тихо да смени библиотека, а slowapi още е в ранен етап. Ако
python3 -m venvсе оплаче, че липсва модул, инсталирай пакетаpython3-venv(⚠️ на GB10 не сме го пробвали). -
Ключове — генерирани, не измисляни
Давай отделен ключ на всеки клиент. Така един ключ се маха, без да счупиш другите, а лимитът следи всеки поотделно. Ключовете живеят във файл
.env, не в кода.bash · ~/ollama-gateway/.envcat > .env <<EOF PROXY_API_KEYS=$(openssl rand -hex 24),$(openssl rand -hex 24) OLLAMA_URL=http://127.0.0.1:11434 ALLOWED_MODELS=llama3.1 RATE_LIMIT=20/minute EOF chmod 600 .env set -a; . ./.env; set +aПървият ключ е за първия клиент, вторият — за втория. Виж ги с
cat .envи ги дай на клиентите по защитен канал. -
Файлът main.py
Целият шлюз е в един файл. Чети коментарите — всеки ред си има причина.
python · ~/ollama-gateway/main.py# main.py — authenticated, rate-limited proxy in front of Ollama import hashlib, hmac, json, os from contextlib import asynccontextmanager import httpx from fastapi import Depends, FastAPI, HTTPException, Request, Security from fastapi.responses import JSONResponse, StreamingResponse from fastapi.security import APIKeyHeader from pydantic import BaseModel, Field from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434") API_KEYS = [k for k in os.environ.get("PROXY_API_KEYS", "").split(",") if k] ALLOWED_MODELS = {m for m in os.environ.get("ALLOWED_MODELS", "llama3.1").split(",") if m} RATE = os.environ.get("RATE_LIMIT", "20/minute") @asynccontextmanager async def lifespan(app: FastAPI): app.state.http = httpx.AsyncClient( base_url=OLLAMA_URL, timeout=httpx.Timeout(connect=5, read=300, write=30, pool=5), ) yield await app.state.http.aclose() app = FastAPI(title="Ollama gateway", version="1.0", lifespan=lifespan) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) def require_key(key: str | None = Security(api_key_header)) -> str: # constant-time comparison against every configured key ok = key is not None and any(hmac.compare_digest(key, k) for k in API_KEYS) if not ok: raise HTTPException(status_code=401, detail="invalid or missing API key") return key def limit_key(request: Request) -> str: # one bucket per client key (store only a short hash, never the key itself) k = request.headers.get("x-api-key", "") return hashlib.sha256(k.encode()).hexdigest()[:16] if k else (request.client.host if request.client else "anon") limiter = Limiter(key_func=limit_key) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) class Message(BaseModel): role: str = Field(pattern="^(system|user|assistant)$") content: str = Field(max_length=16000) class ChatIn(BaseModel): model: str messages: list[Message] = Field(min_length=1, max_length=40) stream: bool = True @app.get("/healthz") async def healthz(): return {"status": "ok"} @app.post("/v1/chat") @limiter.limit(RATE) async def chat(request: Request, body: ChatIn, key: str = Depends(require_key)): if body.model not in ALLOWED_MODELS: raise HTTPException(status_code=400, detail="model not allowed") # forward ONLY the fields we choose; never pass client JSON through as-is payload = {"model": body.model, "stream": body.stream, "messages": [m.model_dump() for m in body.messages]} client: httpx.AsyncClient = request.app.state.http try: upstream = await client.send( client.build_request("POST", "/api/chat", json=payload), stream=True) except httpx.HTTPError: raise HTTPException(status_code=502, detail="model server unreachable") if upstream.status_code != 200: await upstream.aclose() raise HTTPException(status_code=502, detail="model server error") if not body.stream: try: data = json.loads(await upstream.aread()) finally: await upstream.aclose() return JSONResponse(data) async def relay(): try: async for line in upstream.aiter_lines(): if line: yield line + "\n" finally: await upstream.aclose() # also runs when the client disconnects return StreamingResponse(relay(), media_type="application/x-ndjson", headers={"X-Accel-Buffering": "no"})💡Защо точно такаКлюч в заглавка — прост и достатъчен за връзка между услуги. OAuth2 (в FastAPI има готови класове) е за влизане на хора, обхвати и изтичащи токени.compare_digest— сравнение за постоянно време, не издава колко знака съвпадат. Лимит по хеш на ключа — всеки клиент има своя кофа, а в паметта не стои самият ключ. Тялото се проверява и се предава само избрано — клиентът не може да поиска друг модел или опция, нито да прокара чужди полета. Състоянието се гледа преди потока — ако Ollama върне грешка, клиентът получава 502, а не „200“ с празно тяло.finally— връзката към Ollama се затваря и когато клиентът си тръгне по средата.⚠️Три капана на slowapi1) Декораторът@limiter.limitтрябва да е под този на маршрута. 2) Функцията трябва да приема аргументrequest: Request, иначе лимитът не се закача. 3) Броячите по подразбиране са в паметта на процеса: при няколко работни процеса всеки брои сам (нужен е Redis — библиотеката поддържа и такъв; ⚠️ не сме го пробвали). Освен това документацията казва, че WebSocket не се поддържа. -
Пусни шлюза и пробвай
bash · първи прозорецcd ~/ollama-gateway && . .venv/bin/activate set -a; . ./.env; set +a uvicorn main:app --host 127.0.0.1 --port 8000Във втори прозорец пробвай по ред. Първата заявка е без ключ, втората — с ключ и поток:
bash · втори прозорец# без ключ → 401 curl -s -w " %{http_code}\n" -X POST http://127.0.0.1:8000/v1/chat \ -H "Content-Type: application/json" \ -d '{"model":"llama3.1","messages":[{"role":"user","content":"Здравей"}]}' # с ключ → редове JSON, един по един (-N изключва буфера на curl) curl -N -X POST http://127.0.0.1:8000/v1/chat \ -H "X-API-Key: <твоят-ключ>" \ -H "Content-Type: application/json" \ -d '{"model":"llama3.1","messages":[{"role":"user","content":"Здравей"}]}'Втората заявка връща ред за всяка порция текст в
message.content; последният ред има"done": true. Добави"stream": falseв тялото и получаваш един цял отговор. ⚠️ С истински модел не сме го пускали — при първата заявка след зареждане на модела може да се чака повече.✅Какво видяхме в пясъчникаС имитацията на Ollama: без ключ — 401; грешен ключ — 401; неразрешен модел — 400; при лимит 5 в минута петата заявка минава, а шестата връща 429 и другият ключ не се засяга; с изключен Ollama — 502. Потокът започна за около 0,02 секунди, а целият отговор отне около 1 секунда — значи частите наистина идват наведнъж с генерирането, а не накрая.⚠️Грешен ключ не се брои в лимитаПроверката на ключа върви преди лимита, затова заявки с грешен ключ получават 401, но slowapi не ги брои. Който налива такива заявки, не е ограничен тук — спира се на обратния прокси (следващата стъпка). -
Обратен прокси с TLS
Клиентите не бива да стигат до порт 8000. Пред шлюза слагаме nginx: той държи TLS, пази втори лимит по адрес (за заявки с грешен ключ) и не буферира потока. Адресът
api.example.comи пътищата до сертификата са плейсхолдъри.nginx · /etc/nginx/conf.d/ollama-gateway.conflimit_req_zone $binary_remote_addr zone=gw:10m rate=10r/s; server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/ssl/gateway/fullchain.pem; ssl_certificate_key /etc/ssl/gateway/privkey.pem; client_max_body_size 1m; location /v1/ { limit_req zone=gw burst=20 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_buffering off; # потокът да не се трупа в nginx proxy_read_timeout 300s; # докато моделът мисли proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } }Имената на настройките са сверени с документацията на nginx; самата конфигурация не сме я пускали ⚠️. Проверката на файла преди зареждане е
sudo nginx -t. Сертификатът е от доверен издател или от твоя вътрешен; как се издава е друга тема. Нашият шлюз брои лимита по ключ, а не по адрес, затова не му трябват препратените адреси. Ако някога го преминеш на броене по адрес, пусни uvicorn с--forwarded-allow-ips, зададен на адреса на прокси сървъра — никога със „*“ (по страницата на FastAPI за работа зад прокси).✅Нищо друго не бива да слушаПровери:ss -ltn | grep -E ':(8000|11434) '— само127.0.0.1. Към мрежата е отворен само портът на прокси сървъра (и този за SSH, ако ти трябва). Една проста стена на машината го пази и ако се объркат настройките. -
Ключът на всеки клиент и вратата към мрежата
- Всяко приложение, което ползва шлюза, получава свой ключ в
PROXY_API_KEYS. Маханият ключ се изтрива от списъка и шлюзът се рестартира. - Ключът се праща само през HTTPS и се пази в тайното хранилище на клиента, не в кода на страница, която хората отварят.
- Не пускай CORS за „*“. Ако браузър извиква шлюза, добави само адреса на твоята страница (
CORSMiddlewareс точен списък), а ключът пак не бива да е в самата страница — извиквай шлюза от твой сървър. - Моделът пише препоръка, не решение. Ако отговорът води до действие, нека го одобрява човек.
- Всяко приложение, което ползва шлюза, получава свой ключ в
04Проверка
ollama --versionпоказва 0.35.0 и Ollama слуша само на127.0.0.1.- Заявка без ключ и с грешен ключ връща 401; с неразрешен модел — 400.
- Заявка с ключ връща редове JSON един по един; последният има
"done": true. - Над лимита заявката връща 429, а вторият ключ не е засегнат.
- С изключен Ollama шлюзът връща 502, а не дълъг запис за грешка.
- Шлюзът слуша само на
127.0.0.1:8000; отвън се отваря само прокси сървърът с TLS. - Всеки клиент има свой ключ и един може да се махне, без да се пипат другите.
Тест
1. На кой адрес бива да слуша Ollama в тази схема?
2. Къде се спират заявките с грешен ключ, ако някой ги налива масово?
3. Защо предаваме потока по HTTP (редове JSON), а не по WebSocket?
4. Защо шлюзът проверява тялото и пази списък на разрешените модели?
05Какво следва
06Източници
- FastAPI в PyPI · slowapi · httpx · uvicorn — версии и дати на изданията.
- FastAPI: Security (APIKeyHeader, OAuth2) · зад прокси · StreamingResponse.
- slowapi: документация — настройка с FastAPI, ред на декораторите, ограничения (WebSocket, alpha).
- Ollama: API /api/chat 🔒 локално — съобщения,
stream, поток от редове JSON · издания на Ollama (0.35.0). - nginx: proxy · limit_req · ssl · издания.