Files
recipe/recipe_service/app.py
T

147 lines
4.8 KiB
Python

import json
import os
import hmac
import time
import requests
from flask import Flask, jsonify, request
from werkzeug.middleware.proxy_fix import ProxyFix
try:
from recipe_service.metrics import count_since, initialize, record, request_context, usage_json
except ModuleNotFoundError:
from metrics import count_since, initialize, record, request_context, usage_json
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
MAX_IMAGE_BYTES = 10 * 1024 * 1024
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
PROXY_URL = "http://127.0.0.1:8768/gemini"
RATE_LIMIT_REQUESTS_PER_MINUTE = 20
initialize()
def settings() -> dict:
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 config
def authorized() -> bool:
expected = os.environ.get("RECIPE_API_TOKEN")
authorization = request.headers.get("Authorization", "")
return bool(expected and hmac.compare_digest(authorization, f"Bearer {expected}"))
def minute_start_utc(epoch_seconds: float) -> str:
return time.strftime("%Y-%m-%dT%H:%M:00Z", time.gmtime(epoch_seconds))
def is_rate_limited(client_ip: str, now_epoch: float) -> bool:
if not client_ip:
return False
window_start = minute_start_utc(now_epoch)
return count_since(client_ip=client_ip, started_at_from=window_start) >= RATE_LIMIT_REQUESTS_PER_MINUTE
@app.get("/health")
@app.get("/receipt/health")
def health():
return jsonify(status="ok")
@app.post("/receipt")
@app.post("/receipt/")
@app.post("/recipe")
@app.post("/recipe/")
def recipe():
request_id, started_at, started_monotonic = request_context()
image_mime = None
image_bytes = None
prompt_chars = None
usage = {}
client_ip = request.remote_addr
def finalize(response, status_code: int, error: str | None):
duration_ms = int((time.monotonic() - started_monotonic) * 1000)
response_bytes = len(response.get_data())
record(
request_id=request_id,
started_at=started_at,
client_ip=client_ip,
user_agent=request.user_agent.string,
method=request.method,
path=request.path,
image_mime=image_mime,
image_bytes=image_bytes,
prompt_chars=prompt_chars,
status_code=status_code,
duration_ms=duration_ms,
response_bytes=response_bytes,
usage_json=usage_json(usage),
error=error,
)
return response, status_code
if not authorized():
return finalize(jsonify(error="unauthorized"), 401, "unauthorized")
if is_rate_limited(client_ip=client_ip or "", now_epoch=time.time()):
return finalize(
jsonify(error="too many requests", code="rate_limited"),
429,
"rate_limited",
)
image = request.files.get("image")
prompt = request.form.get("prompt")
if image is None or not prompt:
return finalize(jsonify(error="image and prompt are required"), 400, "image and prompt are required")
prompt_chars = len(prompt)
if image.mimetype not in ALLOWED_TYPES:
image_mime = image.mimetype
return finalize(jsonify(error="unsupported image type"), 415, "unsupported image type")
image_data = image.read(MAX_IMAGE_BYTES + 1)
image_mime = image.mimetype
image_bytes = len(image_data)
if len(image_data) > MAX_IMAGE_BYTES:
return finalize(jsonify(error="image is too large"), 413, "image is too large")
try:
config = settings()
response = requests.post(
os.environ.get("GEMINI_PROXY_URL", PROXY_URL),
files={"image": (image.filename or "image", image_data, image.mimetype)},
data={"prompt": prompt, "generation_config": json.dumps(config)},
timeout=180,
)
except (requests.RequestException, RuntimeError) as exc:
error = str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"
return finalize(jsonify(error=error), 503, error)
if response.status_code != 200:
try:
detail = response.json().get("error", {}).get("message", "Gemini request failed")
except ValueError:
detail = "Gemini request failed"
app.logger.warning("upstream_failure request_id=%s detail=%s", request_id, detail)
return finalize(
jsonify(error="upstream recognition failed", code="upstream_error"),
502,
"upstream_error",
)
data = response.json()
usage = data.get("usage", {})
result = jsonify(text=data.get("text"), usage=usage)
return finalize(result, 200, None)