Add recipe Gemini proxy service

This commit is contained in:
“Naeel”
2026-08-29 02:17:25 +03:00
parent df5afcec8f
commit 17def7789b
7 changed files with 149 additions and 0 deletions
+28
View File
@@ -383,6 +383,34 @@ Proxy изменён: `POST /gemini` теперь принимает multipart-
Usage проверки: prompt 1117, image 1102, text 15, candidate output 80,
thoughts 1760, total 2957. Ключ в журнал не записывался.
## Минимальный recipe-сервис на ВМ 213
По команде пользователя создан отдельный Flask-сервис
`recipe_service/`. Его назначение на текущем этапе: принять картинку и prompt,
передать их в Gemini с ключом и JSON-настройками из серверного EnvironmentFile,
вернуть `text` и `usage`. Разбор рецепта и дополнительная логика пока не
реализуются.
Развёрнуты отдельные каталог `/opt/recipe`, пользователь `recipe`, virtualenv,
systemd-юнит `recipe.service` и внутренний bind `127.0.0.1:8770`. Порт 8769
был занят существующим процессом, поэтому выбран 8770; внешний URL от этого не
меняется.
Ключ и настройки записаны только на ВМ в `/etc/recipe/recipe.env` с правами
`0640`, без хардкода в коде и без записи в HISTORY. В конфигурации задана
модель `gemini-3.6-flash`, лимит 300 токенов и `thinkingLevel=minimal`.
Добавлен отдельный nginx location `https://obdai.ru/recipe/`, проксирующий на
`127.0.0.1:8770`. `nginx -t` успешен, внешний `GET /recipe/health` вернул
`{"status":"ok"}`.
Ошибки и исправления: первая команда деплоя использовала путь к ключу,
существующий только на локальной машине, и получила `No such file`; ключ затем
передан через stdin. Первая health-проверка обращалась к занятому старому
порту/в момент старта; после переноса на 8770 сервис работает. Первый внешний
health сразу после reload дал кратковременный 404, повторная проверка вернула
200.
## Проверка `PLAN/lekar1.png` с минимальным thinking
Предыдущий тест использовал ошибочный crop из `lekar.png`. По уточнению
+3
View File
@@ -0,0 +1,3 @@
GEMINI_API_KEY=replace-with-secret
GEMINI_MODEL=gemini-3.6-flash
GEMINI_GENERATION_CONFIG={"temperature":0,"maxOutputTokens":300,"thinkingConfig":{"thinkingLevel":"minimal"}}
+13
View File
@@ -0,0 +1,13 @@
# obdai.ru/recipe
Минимальный Flask-сервис на ВМ `5.172.178.213`.
Вход: `POST /recipe` в формате `multipart/form-data` с полями `image` и
`prompt`. Изображение: JPEG/PNG/WEBP, не более 10 MB.
Ключ Gemini и JSON-строка `GEMINI_GENERATION_CONFIG` находятся только в
серверном EnvironmentFile. Сервис передаёт изображение, prompt и настройки в
Gemini и возвращает `text` и `usage`; разбор рецепта выполняется позднее.
Сервис изолирован от `elmer`: отдельные каталог, virtualenv, пользователь,
порт, systemd-юнит и настройки nginx. Изображения на диск не сохраняются.
+70
View File
@@ -0,0 +1,70 @@
import base64
import json
import os
import requests
from flask import Flask, jsonify, request
app = Flask(__name__)
MAX_IMAGE_BYTES = 10 * 1024 * 1024
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models"
def settings() -> tuple[str, dict]:
key = os.environ.get("GEMINI_API_KEY")
if not key:
raise RuntimeError("GEMINI_API_KEY is not configured")
try:
config = json.loads(os.environ.get("GEMINI_GENERATION_CONFIG", "{}"))
except json.JSONDecodeError as exc:
raise RuntimeError("GEMINI_GENERATION_CONFIG is invalid") from exc
if not isinstance(config, dict):
raise RuntimeError("GEMINI_GENERATION_CONFIG must be an object")
return key, config
@app.get("/health")
def health():
return jsonify(status="ok")
@app.post("/recipe")
def recipe():
image = request.files.get("image")
prompt = request.form.get("prompt")
if image is None or not prompt:
return jsonify(error="image and prompt are required"), 400
if image.mimetype not in ALLOWED_TYPES:
return jsonify(error="unsupported image type"), 415
image_data = image.read(MAX_IMAGE_BYTES + 1)
if len(image_data) > MAX_IMAGE_BYTES:
return jsonify(error="image is too large"), 413
try:
key, config = settings()
response = requests.post(
f"{GEMINI_URL}/{os.environ.get('GEMINI_MODEL', 'gemini-3.6-flash')}:generateContent",
params={"key": key},
json={
"contents": [{"parts": [{"text": prompt}, {"inline_data": {
"mime_type": image.mimetype,
"data": base64.b64encode(image_data).decode("ascii"),
}}]}],
"generationConfig": config,
},
timeout=180,
)
except (requests.RequestException, RuntimeError) as exc:
return jsonify(error=str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"), 503
if response.status_code != 200:
try:
detail = response.json().get("error", {}).get("message", "Gemini request failed")
except ValueError:
detail = "Gemini request failed"
return jsonify(error=detail), 502
data = response.json()
return jsonify(
text=data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text"),
usage=data.get("usageMetadata", {}),
)
+12
View File
@@ -0,0 +1,12 @@
location /recipe/ {
proxy_pass http://127.0.0.1:8770/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 10m;
proxy_connect_timeout 10s;
proxy_send_timeout 200s;
proxy_read_timeout 200s;
}
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=Minimal recipe Gemini proxy
After=network-online.target
Wants=network-online.target
[Service]
User=recipe
Group=recipe
WorkingDirectory=/opt/recipe
EnvironmentFile=/etc/recipe/recipe.env
ExecStart=/opt/recipe/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8770 --timeout 200 app:app
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
Flask>=3.0,<4
gunicorn>=21.2,<24
requests>=2.31,<3