Знакът на КАГАМИ КАГАМИ
kagami.bg/academy · lesson · machine-readable viewVERIFIED 2026-10-01 · UPDATED 2026-10-01
IDENTITY
module
GX10-04-114 · FastAPI gateway in front of a local Ollama: API-key auth, rate limit, streaming proxy
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Intermediate
duration
about 2 h
prerequisites
A GB10-class machine with DGX OS (Ubuntu 24.04, Arm64) and Ollama installed as a host service; shell access (local or SSH); Python 3.10 or newer; basic HTTP and curl
trust_label
VERIFIED 2026-10-01 (package versions read from PyPI; FastAPI, slowapi, Ollama and nginx documentation read) · UPDATED 2026-10-01 · NOT TESTED (no TESTED label): the app code was RUN in a sandbox against a MOCK Ollama server that imitates the NDJSON shape of /api/chat (Linux, Python 3.10, not a GB10 machine, not a real model). The reverse-proxy config and TLS were not run
versions
FastAPI 0.142.2 (PyPI, 2026-09-30; Python 3.10+) · slowapi 0.1.10 (PyPI, 2026-06-13; the project calls itself alpha quality) · httpx 0.28.1 · uvicorn 0.54.0 · Ollama 0.35.0 · nginx stable 1.30.5 / mainline 1.31.6 (nginx.org download page)
language
human view: bg · english edition: /en/academy/gx10/ (same file name)
previous / next
series order: 04-113_BGE_M3_Semantic_Search.html / 04-115_Model_Selection_Trade_offs.html (related: 04-116_Morning_Dashboard_Appsmith.html)
PURPOSE

Put a small authenticated, rate-limited gateway between client applications and a local Ollama server. Clients send POST /v1/chat with an X-API-Key header; the gateway checks the key, applies a per-key limit, validates the body against a model allow-list, forwards only whitelisted fields to Ollama POST /api/chat, and relays the answer either as one JSON object or as a stream of JSON lines. Ollama and the gateway listen only on loopback; a reverse proxy terminates TLS and is the only public entry. All keys and hostnames in the lesson are placeholders.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

04-115 · Choosing a model: memory, speed and quality (04-115_Model_Selection_Trade_offs.html) · related: 04-116 morning dashboard with Appsmith over PostgreSQL and a local model, 04-113 hybrid search API · series index: kagami.bg/academy/gx10/ · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
gx10nvidia-gb10fastapiollamaapi-keyrate-limitslowapistreamingreverse-proxytls
ПРОВЕРЕНО · 01.10.2026 ОБНОВЕНО · 01.10.2026

FastAPI шлюз пред Ollama: ключ и лимит

Ollama няма собствена защита: който стигне до порта му, може да ползва машината ти. Затова пред него слагаме малък шлюз с FastAPI — проверява ключ, пази лимит на заявките, предава отговора на потоци (дума по дума) и е единственият път към модела. Моделът остава на машината, а към мрежата се вижда само обратният прокси с TLS.

⏱ около 2 ч Средно GX10 NVIDIA GB10 · 20 ядра Arm · 128 GB обща памет FastAPI · Ollama · nginx
FastAPI · slowapi · httpx🔒 локално Ollama (моделът)🔒 локално Обратен прокси nginx (TLS)🔒 локално
🔄
ОБНОВЕНО · 01.10.2026 — какво
Урокът е преработен по текущите документи и стана публичен. Махнахме: достъп отвсякъде (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 и втори лимит. Примерите са измислени.
⚠️
Какво не сме пускали сами
Кодът на шлюза го пуснахме в пясъчник срещу имитация на Ollama (малък сървър, който връща същия формат на редове като /api/chat) — не срещу истински модел и не на машина от класа GB10. Затова няма етикет „ТЕСТВАНО“. Не сме пускали: конфигурацията на nginx, TLS сертификатите, работата с истински модел и скоростта му, няколко работни процеса и Python 3.12 от DGX OS. Библиотеката slowapi сама се определя като „alpha quality“ — следи изданията ѝ.

01Какво ще научиш

02Преди да започнеш

КаквоСтойност (проверено на 01.10.2026)
FastAPI0.142.2 (PyPI, 30.09.2026), Python 3.10+
slowapi0.1.10 (PyPI, 13.06.2026) — обвивка над библиотеката limits
httpx · uvicorn0.28.1 · 0.54.0
Ollama0.35.0 (GitHub, 28.09.2026)
nginxстабилна 1.30.5, основна 1.31.6 (nginx.org)
🧪
Всичко в примера е измислено
Ключовете, домейнът api.example.com и пътищата са плейсхолдъри. Ключове си генерираш сам — никога не копирай чужд.

03Стъпки

  1. Какво ще стои къде

    Път на една заявка: клиент → HTTPS към обратния прокси → шлюз (FastAPI) → Ollama. Шлюзът и Ollama слушат само на 127.0.0.1, значи отвън не се виждат. Защо шлюз? Ollama няма вход с парола; шлюзът добавя ключ, лимит, проверка на тялото и записи, без да пипаш самия модел.

    ЧастЗадачаСлуша на
    nginxTLS, първи лимит, пренасочванепубличния порт 443
    FastAPI шлюзключ, лимит на клиент, проверка, поток127.0.0.1:8000
    Ollamaмоделът127.0.0.1:11434
  2. Провери 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: така моделът става достъпен за цялата мрежа, и то без парола.

  3. Папка, среда и пакети със закачени версии

    bash
    mkdir -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 не сме го пробвали).

  4. Ключове — генерирани, не измисляни

    Давай отделен ключ на всеки клиент. Така един ключ се маха, без да счупиш другите, а лимитът следи всеки поотделно. Ключовете живеят във файл .env, не в кода.

    bash · ~/ollama-gateway/.env
    cat > .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 и ги дай на клиентите по защитен канал.

  5. Файлът 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 се затваря и когато клиентът си тръгне по средата.
    ⚠️
    Три капана на slowapi
    1) Декораторът @limiter.limit трябва да е под този на маршрута. 2) Функцията трябва да приема аргумент request: Request, иначе лимитът не се закача. 3) Броячите по подразбиране са в паметта на процеса: при няколко работни процеса всеки брои сам (нужен е Redis — библиотеката поддържа и такъв; ⚠️ не сме го пробвали). Освен това документацията казва, че WebSocket не се поддържа.
  6. Пусни шлюза и пробвай

    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 не ги брои. Който налива такива заявки, не е ограничен тук — спира се на обратния прокси (следващата стъпка).
  7. Обратен прокси с TLS

    Клиентите не бива да стигат до порт 8000. Пред шлюза слагаме nginx: той държи TLS, пази втори лимит по адрес (за заявки с грешен ключ) и не буферира потока. Адресът api.example.com и пътищата до сертификата са плейсхолдъри.

    nginx · /etc/nginx/conf.d/ollama-gateway.conf
    limit_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, ако ти трябва). Една проста стена на машината го пази и ако се объркат настройките.
  8. Ключът на всеки клиент и вратата към мрежата

    • Всяко приложение, което ползва шлюза, получава свой ключ в PROXY_API_KEYS. Маханият ключ се изтрива от списъка и шлюзът се рестартира.
    • Ключът се праща само през HTTPS и се пази в тайното хранилище на клиента, не в кода на страница, която хората отварят.
    • Не пускай CORS за „*“. Ако браузър извиква шлюза, добави само адреса на твоята страница (CORSMiddleware с точен списък), а ключът пак не бива да е в самата страница — извиквай шлюза от твой сървър.
    • Моделът пише препоръка, не решение. Ако отговорът води до действие, нека го одобрява човек.

04Проверка

Тест

1. На кой адрес бива да слуша Ollama в тази схема?

2. Къде се спират заявките с грешен ключ, ако някой ги налива масово?

3. Защо предаваме потока по HTTP (редове JSON), а не по WebSocket?

4. Защо шлюзът проверява тялото и пази списък на разрешените модели?

05Какво следва

06Източници

  1. FastAPI в PyPI · slowapi · httpx · uvicorn — версии и дати на изданията.
  2. FastAPI: Security (APIKeyHeader, OAuth2) · зад прокси · StreamingResponse.
  3. slowapi: документация — настройка с FastAPI, ред на декораторите, ограничения (WebSocket, alpha).
  4. Ollama: API /api/chat 🔒 локално — съобщения, stream, поток от редове JSON · издания на Ollama (0.35.0).
  5. nginx: proxy · limit_req · ssl · издания.