Add recipe Gemini proxy service
This commit is contained in:
@@ -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`. По уточнению
|
||||
|
||||
@@ -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"}}
|
||||
@@ -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. Изображения на диск не сохраняются.
|
||||
@@ -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", {}),
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
Flask>=3.0,<4
|
||||
gunicorn>=21.2,<24
|
||||
requests>=2.31,<3
|
||||
Reference in New Issue
Block a user