Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .coverage
Binary file not shown.
84 changes: 64 additions & 20 deletions app/features/image_analysis/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@
from pathlib import Path
from typing import List
from uuid import uuid4
from urllib.parse import urlparse

from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from pydantic import BaseModel, ConfigDict

from app.features.chat.service import THREAD_MODEL_OVERRIDES
from app.features.image_analysis.service import build_image_conversation, call_openrouter_for_image
from app.features.image_analysis.service import (
build_image_conversation,
call_agentrouter_for_image,
call_openrouter_for_image,
)
from app.logging import get_logger
from app.middlewares.security import _require_csrf_token
from app.security_layer.dependencies import require_session
Expand Down Expand Up @@ -52,6 +57,10 @@ async def analyze_image_endpoint(
history: str = Form("[]"),
open_router_api_key: str | None = Form(default=None),
open_router_model: str | None = Form(default=None),
agent_router_api_key: str | None = Form(default=None),
agent_router_model: str | None = Form(default=None),
agent_router_base_url: str | None = Form(default=None),
provider_type: str = Form(default="openrouter"),
system_prompt: str | None = Form(default=None),
history_message_count: int = Form(default=5),
session=Depends(require_session),
Expand Down Expand Up @@ -81,19 +90,6 @@ async def analyze_image_endpoint(
bool(message.strip()),
)

actual_api_key = open_router_api_key or settings.openrouter_api_key
sanitized_model = (open_router_model or "").strip()
if sanitized_model:
THREAD_MODEL_OVERRIDES[thread_id] = sanitized_model

model_from_thread = THREAD_MODEL_OVERRIDES.get(thread_id)
actual_model = model_from_thread or settings.openrouter_model

if not actual_api_key:
raise HTTPException(status_code=400, detail="OpenRouter API ключ не настроен")
if not actual_model:
raise HTTPException(status_code=400, detail="OpenRouter модель не настроена")

try:
history_payload = json.loads(history) if history else []
if not isinstance(history_payload, list):
Expand All @@ -105,6 +101,10 @@ async def analyze_image_endpoint(
encoded_images: List[str] = []
response_images: List[ImagePayload] = []

provider = (provider_type or "openrouter").strip().lower()
if provider not in {"openrouter", "agentrouter"}:
provider = "openrouter"

for upload in files:
if not upload.content_type or not upload.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail=f"Файл {upload.filename or ''} не является изображением")
Expand Down Expand Up @@ -153,6 +153,42 @@ async def analyze_image_endpoint(

origin = request.headers.get("Origin") or request.headers.get("Referer")

if provider == "agentrouter":
actual_api_key = (agent_router_api_key or "").strip() or None
actual_model = (agent_router_model or "").strip() or None
base_url = (agent_router_base_url or "").strip() or None
if not actual_api_key:
raise HTTPException(status_code=400, detail="OpenAI Compatible API ключ не настроен")
if not base_url:
raise HTTPException(status_code=400, detail="OpenAI Compatible endpoint не настроен")

try:
parsed = urlparse(base_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail="OpenAI Compatible endpoint некорректен") from exc
if parsed.scheme.lower() != "https":
raise HTTPException(status_code=400, detail="OpenAI Compatible endpoint должен использовать HTTPS")
normalized_origin = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
allowlist = {item.rstrip("/").lower() for item in settings.allowed_agentrouter_base_urls}
if allowlist and normalized_origin not in allowlist:
raise HTTPException(status_code=403, detail="OpenAI Compatible endpoint не разрешён")

if not actual_model:
raise HTTPException(status_code=400, detail="OpenAI Compatible модель не настроена")
else:
actual_api_key = (open_router_api_key or settings.openrouter_api_key or "").strip() or None
sanitized_model = (open_router_model or "").strip()
if sanitized_model:
THREAD_MODEL_OVERRIDES[thread_id] = sanitized_model

model_from_thread = THREAD_MODEL_OVERRIDES.get(thread_id)
actual_model = (model_from_thread or settings.openrouter_model or "").strip() or None

if not actual_api_key:
raise HTTPException(status_code=400, detail="OpenRouter API ключ не настроен")
if not actual_model:
raise HTTPException(status_code=400, detail="OpenRouter модель не настроена")

messages = build_image_conversation(
history=history_payload,
thread_id=thread_id,
Expand All @@ -163,12 +199,20 @@ async def analyze_image_endpoint(
)

try:
response_text = call_openrouter_for_image(
messages=messages,
api_key=actual_api_key,
model=actual_model,
origin=origin,
)
if provider == "agentrouter":
response_text = call_agentrouter_for_image(
messages=messages,
api_key=actual_api_key,
model=actual_model,
base_url=base_url, # type: ignore[arg-type]
)
else:
response_text = call_openrouter_for_image(
messages=messages,
api_key=actual_api_key,
model=actual_model,
origin=origin,
)
except HTTPException as exc:
if exc.status_code == 502 and "does not support" in str(exc.detail).lower():
raise HTTPException(status_code=400, detail="Выбранная модель не поддерживает работу с изображениями.") from exc
Expand Down
58 changes: 57 additions & 1 deletion app/features/image_analysis/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,66 @@ def call_openrouter_for_image(messages: list[dict], api_key: str, model: str, or
)

data = response.json()
return _extract_image_description(data)


def call_agentrouter_for_image(messages: list[dict], api_key: str, model: str, base_url: str) -> str:
if not base_url:
raise HTTPException(status_code=400, detail="OpenAI Compatible endpoint не настроен")

endpoint = f"{base_url.rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
payload = {
"model": model,
"messages": messages,
"temperature": 0.7,
"max_tokens": settings.max_completion_tokens,
}

try:
response = requests.post(
endpoint,
headers=headers,
json=payload,
timeout=90,
)
except requests.RequestException as exc:
logger.error("[IMAGE ANALYSIS] Ошибка запроса к OpenAI Compatible: %s", exc)
raise HTTPException(status_code=502, detail=f"OpenAI Compatible error: {exc}") from exc

if not response.ok:
try:
payload = response.json()
error_detail = payload.get("error") or payload.get("message")
except ValueError:
error_detail = response.text

logger.error(
"[IMAGE ANALYSIS] OpenAI Compatible non-OK response: status=%s detail=%s",
response.status_code,
error_detail,
)
status = response.status_code
if status < 400 or status > 499:
status = 502
raise HTTPException(
status_code=status,
detail=f"OpenAI Compatible error ({response.status_code}): {error_detail or 'Unknown error'}",
)

data = response.json()
return _extract_image_description(data)


def _extract_image_description(data: dict) -> str:
message = data.get("choices", [{}])[0].get("message") or {}
content = message.get("content")
if not content:
logger.warning("[IMAGE ANALYSIS] Пустой ответ от OpenRouter: %s", data)
logger.warning("[IMAGE ANALYSIS] Пустой ответ модели: %s", data)
return "Не удалось получить описание изображения."

if isinstance(content, list):
Expand Down
Loading