Add isolated Gemini image proxy
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
GEMINI_API_KEY=replace-with-secret
|
||||
GEMINI_MODEL=gemini-3.6-flash
|
||||
@@ -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
|
||||
доступен только локально на немецкой ВМ.
|
||||
@@ -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", {})}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.115,<1
|
||||
httpx>=0.27,<1
|
||||
python-multipart>=0.0.9,<1
|
||||
uvicorn[standard]>=0.30,<1
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user