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,
|
Usage проверки: prompt 1117, image 1102, text 15, candidate output 80,
|
||||||
thoughts 1760, total 2957. Ключ в журнал не записывался.
|
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
|
## Проверка `PLAN/lekar1.png` с минимальным thinking
|
||||||
|
|
||||||
Предыдущий тест использовал ошибочный crop из `lekar.png`. По уточнению
|
Предыдущий тест использовал ошибочный 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