Add isolated Gemini image proxy

This commit is contained in:
“Naeel”
2026-08-28 19:20:51 +03:00
parent ec57cf5d57
commit df5afcec8f
11 changed files with 849 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
GEMINI_API_KEY=replace-with-secret
GEMINI_MODEL=gemini-3.6-flash
+76
View File
@@ -0,0 +1,76 @@
# Gemini Proxy
Минимальный изолированный HTTP-прокси для вызова Gemini с изображением.
## Назначение
Прокси принимает один crop изображения от основного российского сервера,
передаёт его в Gemini Developer API и возвращает текст модели вместе с
метаданными использования токенов.
В прокси нет бизнес-логики распознавания рецептов, словаря лекарств,
нормализации, scoring или хранения результатов. Эти функции находятся на
основном сервере.
## API
### `GET /health`
Возвращает:
```json
{"status":"ok"}
```
### `POST /gemini`
Формат: `multipart/form-data`.
Поле `image` должно содержать JPEG, PNG или WEBP размером не более 10 MB.
Необязательное поле `api_key_override` позволяет передать ключ только для
текущего запроса. Если оно не задано, используется `GEMINI_API_KEY` из
серверной конфигурации. Передача ключа в запросе менее безопасна и допустима
только по защищённому каналу между доверенными серверами; ключ не записывается
прокси в логи или на диск.
Ответ:
```json
{
"text": "{\"date\":null,\"medicines\":[]}",
"usage": {}
}
```
## Секреты
API-ключ не хранится в коде и не коммитится. Сервис читает:
- `GEMINI_API_KEY` — обязательный ключ;
- `GEMINI_MODEL` — модель, по умолчанию `gemini-3.6-flash`.
На сервере секрет хранится отдельно в `/etc/gemini-proxy/gemini-proxy.env`
с правами `600`, владельцем `root:gemini-proxy` и не доступен через HTTP.
## Изоляция
- отдельный пользователь `gemini-proxy`;
- отдельный каталог `/opt/gemini-proxy`;
- отдельный virtualenv;
- отдельный systemd-юнит;
- bind только на `127.0.0.1:8768`;
- изображения обрабатываются в памяти и не сохраняются приложением;
- существующие сервисы и их virtualenv не используются и не перезапускаются.
## Запуск
```text
systemctl status gemini-proxy
systemctl restart gemini-proxy
curl http://127.0.0.1:8768/health
```
Для внешнего доступа потребуется отдельный reverse-proxy маршрут nginx и
аутентификация между российским сервером и этим сервисом. До этого endpoint
доступен только локально на немецкой ВМ.
+89
View File
@@ -0,0 +1,89 @@
import base64
import json
import os
from typing import Annotated
import httpx
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
app = FastAPI(title="Gemini image proxy", docs_url=None, redoc_url=None)
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.6-flash")
GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models"
MAX_IMAGE_BYTES = 10 * 1024 * 1024
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/gemini")
async def recognize(
image: Annotated[UploadFile, File(...)],
prompt: Annotated[str, Form(...)],
generation_config: Annotated[str, Form()] = "{}",
api_key_override: Annotated[str | None, Form()] = None,
) -> dict:
if image.content_type not in ALLOWED_TYPES:
raise HTTPException(status_code=415, detail="Unsupported image type")
image_data = await image.read(MAX_IMAGE_BYTES + 1)
if len(image_data) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail="Image is too large")
api_key = api_key_override or os.getenv("GEMINI_API_KEY")
if not api_key:
raise HTTPException(status_code=503, detail="Gemini is not configured")
try:
config = json.loads(generation_config)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=422, detail="Invalid generation_config") from exc
if not isinstance(config, dict):
raise HTTPException(status_code=422, detail="generation_config must be an object")
payload = {
"contents": [{
"parts": [
{"text": prompt},
{
"inline_data": {
"mime_type": image.content_type,
"data": base64.b64encode(image_data).decode("ascii"),
}
},
]
}],
"generationConfig": config,
}
url = f"{GEMINI_API_URL}/{GEMINI_MODEL}:generateContent"
try:
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
url,
params={"key": api_key},
json=payload,
)
except httpx.HTTPError as exc:
raise HTTPException(status_code=503, detail="Gemini unavailable") from exc
if response.status_code != 200:
try:
provider_error = response.json().get("error", {}).get("message")
except ValueError:
provider_error = None
detail = provider_error or "Gemini request failed"
raise HTTPException(status_code=502, detail=detail)
data = response.json()
try:
candidate = data["candidates"][0]
text = candidate["content"]["parts"][0]["text"]
except (KeyError, IndexError, TypeError) as exc:
raise HTTPException(status_code=502, detail="Invalid Gemini response") from exc
return {"text": text, "usage": data.get("usageMetadata", {})}
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Minimal Gemini image proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=gemini-proxy
Group=gemini-proxy
WorkingDirectory=/opt/gemini-proxy
EnvironmentFile=/etc/gemini-proxy/gemini-proxy.env
ExecStart=/opt/gemini-proxy/venv/bin/uvicorn app:app --host 127.0.0.1 --port 8768
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/run
[Install]
WantedBy=multi-user.target
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.115,<1
httpx>=0.27,<1
python-multipart>=0.0.9,<1
uvicorn[standard]>=0.30,<1
+31
View File
@@ -0,0 +1,31 @@
from fastapi.testclient import TestClient
from app import app
def test_health() -> None:
client = TestClient(app)
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_rejects_unsupported_type() -> None:
client = TestClient(app)
response = client.post(
"/gemini",
files={"image": ("input.txt", b"not-an-image", "text/plain")},
data={"prompt": "test", "generation_config": "{}"},
)
assert response.status_code == 415
def test_missing_key_returns_service_unavailable(monkeypatch) -> None:
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
client = TestClient(app)
response = client.post(
"/gemini",
files={"image": ("input.png", b"not-an-image", "image/png")},
data={"prompt": "test", "generation_config": "{}"},
)
assert response.status_code == 503