Spaces:
Running
Running
sync: 183 file da Baida98/AI@1dcbd977 (2026-08-25 15:06 UTC) [deploy-all] (#50)
Browse files- sync: 183 file da Baida98/AI@1dcbd977 (2026-08-25 15:06 UTC) [deploy-all] (042b26ac5db4395f2676b269717991e332b12148)
- agents/unified_loop_tools.py +18 -14
- api/agent.py +6 -1
- api/image_provider.py +226 -0
- api/vision.py +23 -44
- tests/test_image_provider.py +34 -0
agents/unified_loop_tools.py
CHANGED
|
@@ -360,20 +360,24 @@ class DirectToolsMixin:
|
|
| 360 |
_sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]})
|
| 361 |
if _sc is not None:
|
| 362 |
return _sc
|
| 363 |
-
# Pollinations genera l'immagine quando il browser richiede questo
|
| 364 |
-
# URL. Costruirlo qui rende il direct tool autosufficiente: non
|
| 365 |
-
# dipende da cold-start HF, dal contratto base64 dell'endpoint
|
| 366 |
-
# vision nΓ© da un provider LLM per verbalizzare il risultato.
|
| 367 |
-
from urllib.parse import quote
|
| 368 |
_img_prompt = _img_prompt[:600]
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
_artifact_id = hashlib.sha256(_img_prompt.encode("utf-8")).hexdigest()[:12]
|
| 378 |
_artifact_path = f"generated-image-{_artifact_id}.jpg"
|
| 379 |
if on_step:
|
|
@@ -382,7 +386,7 @@ class DirectToolsMixin:
|
|
| 382 |
"status": "done",
|
| 383 |
"path": _artifact_path,
|
| 384 |
"source_url": img_url,
|
| 385 |
-
"mime_type":
|
| 386 |
"title": "Immagine salvata nel workspace",
|
| 387 |
"explanation": f"Salvo {_artifact_path} nel VFSβ¦",
|
| 388 |
}))
|
|
|
|
| 360 |
_sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]})
|
| 361 |
if _sc is not None:
|
| 362 |
return _sc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
_img_prompt = _img_prompt[:600]
|
| 364 |
+
# Il provider autenticato rimane lato server. Il fallback storico
|
| 365 |
+
# resta solo per garantire la creazione gratuita se il secret non Γ¨
|
| 366 |
+
# ancora disponibile durante un riavvio del runtime.
|
| 367 |
+
try:
|
| 368 |
+
from api.image_provider import generate_pollinations_image
|
| 369 |
+
remote = await generate_pollinations_image(_img_prompt, width=512, height=512)
|
| 370 |
+
img_url = remote.url
|
| 371 |
+
img_mime = remote.mime_type
|
| 372 |
+
except Exception as provider_exc:
|
| 373 |
+
_logger.info("image provider unavailable; using free URL fallback (%s)", type(provider_exc).__name__)
|
| 374 |
+
from urllib.parse import quote
|
| 375 |
+
_img_seed = sum(ord(char) for char in _img_prompt) % 9999 + 1
|
| 376 |
+
img_url = (
|
| 377 |
+
f"https://image.pollinations.ai/prompt/{quote(_img_prompt, safe='')}"
|
| 378 |
+
f"?width=512&height=512&seed={_img_seed}&nologo=true&enhance=true"
|
| 379 |
+
)
|
| 380 |
+
img_mime = "image/jpeg"
|
| 381 |
_artifact_id = hashlib.sha256(_img_prompt.encode("utf-8")).hexdigest()[:12]
|
| 382 |
_artifact_path = f"generated-image-{_artifact_id}.jpg"
|
| 383 |
if on_step:
|
|
|
|
| 386 |
"status": "done",
|
| 387 |
"path": _artifact_path,
|
| 388 |
"source_url": img_url,
|
| 389 |
+
"mime_type": img_mime,
|
| 390 |
"title": "Immagine salvata nel workspace",
|
| 391 |
"explanation": f"Salvo {_artifact_path} nel VFSβ¦",
|
| 392 |
}))
|
api/agent.py
CHANGED
|
@@ -1232,7 +1232,12 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1232 |
elif _action == 'file_written':
|
| 1233 |
_source_url = str(step_data.get('source_url') or '')
|
| 1234 |
_mime_type = str(step_data.get('mime_type') or '')
|
| 1235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1236 |
_mime_type in {'image/jpeg', 'image/png', 'image/webp'}):
|
| 1237 |
_vfs_evt['sourceUrl'] = _source_url[:2_000]
|
| 1238 |
_vfs_evt['mimeType'] = _mime_type
|
|
|
|
| 1232 |
elif _action == 'file_written':
|
| 1233 |
_source_url = str(step_data.get('source_url') or '')
|
| 1234 |
_mime_type = str(step_data.get('mime_type') or '')
|
| 1235 |
+
_allowed_image_origins = (
|
| 1236 |
+
'https://image.pollinations.ai/',
|
| 1237 |
+
'https://media.pollinations.ai/',
|
| 1238 |
+
'https://gen.pollinations.ai/',
|
| 1239 |
+
)
|
| 1240 |
+
if (_source_url.startswith(_allowed_image_origins) and
|
| 1241 |
_mime_type in {'image/jpeg', 'image/png', 'image/webp'}):
|
| 1242 |
_vfs_evt['sourceUrl'] = _source_url[:2_000]
|
| 1243 |
_vfs_evt['mimeType'] = _mime_type
|
api/image_provider.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapter server-side per la generazione e modifica immagini.
|
| 2 |
+
|
| 3 |
+
Le credenziali del provider restano esclusivamente in ``POLLINATIONS_API_KEY``.
|
| 4 |
+
Il modulo restituisce solo URL HTTPS Pollinations validati: nessun token o byte immagine
|
| 5 |
+
viene inoltrato attraverso SSE.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import os
|
| 11 |
+
import random
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from typing import Any
|
| 14 |
+
from urllib.parse import urlparse
|
| 15 |
+
|
| 16 |
+
import httpx
|
| 17 |
+
|
| 18 |
+
POLLINATIONS_BASE_URL = "https://gen.pollinations.ai"
|
| 19 |
+
_ALLOWED_HOSTS = {"gen.pollinations.ai", "image.pollinations.ai", "media.pollinations.ai"}
|
| 20 |
+
_TRANSIENT_STATUS_CODES = {429, 500, 502, 503, 504}
|
| 21 |
+
_MAX_PROMPT_CHARS = 1_200
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class RemoteImage:
|
| 26 |
+
"""Riferimento remoto sicuro da materializzare lato client nel VFS."""
|
| 27 |
+
|
| 28 |
+
url: str
|
| 29 |
+
mime_type: str
|
| 30 |
+
provider: str = "pollinations"
|
| 31 |
+
revised_prompt: str | None = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ImageProviderError(RuntimeError):
|
| 35 |
+
"""Errore sicuro da esporre al chiamante senza leak di segreti provider."""
|
| 36 |
+
|
| 37 |
+
def __init__(self, message: str, *, status_code: int | None = None, retry_after: float | None = None) -> None:
|
| 38 |
+
super().__init__(message)
|
| 39 |
+
self.status_code = status_code
|
| 40 |
+
self.retry_after = retry_after
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def is_pollinations_remote_url(value: str) -> bool:
|
| 44 |
+
"""Accetta solo URL HTTPS del dominio Pollinations controllato dal provider."""
|
| 45 |
+
try:
|
| 46 |
+
parsed = urlparse(value)
|
| 47 |
+
except ValueError:
|
| 48 |
+
return False
|
| 49 |
+
host = (parsed.hostname or "").lower().rstrip(".")
|
| 50 |
+
return (
|
| 51 |
+
parsed.scheme == "https"
|
| 52 |
+
and bool(parsed.path)
|
| 53 |
+
and host in _ALLOWED_HOSTS
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _provider_key() -> str:
|
| 58 |
+
value = os.getenv("POLLINATIONS_API_KEY", "").strip()
|
| 59 |
+
if not value:
|
| 60 |
+
raise ImageProviderError("Il provider immagini server-side non Γ¨ configurato.")
|
| 61 |
+
return value
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _bounded_prompt(value: str) -> str:
|
| 65 |
+
prompt = value.strip()
|
| 66 |
+
if not prompt:
|
| 67 |
+
raise ImageProviderError("Il prompt dellβimmagine Γ¨ obbligatorio.", status_code=400)
|
| 68 |
+
return prompt[:_MAX_PROMPT_CHARS]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _retry_after(response: httpx.Response) -> float | None:
|
| 72 |
+
raw = response.headers.get("retry-after", "").strip()
|
| 73 |
+
try:
|
| 74 |
+
seconds = float(raw)
|
| 75 |
+
except ValueError:
|
| 76 |
+
return None
|
| 77 |
+
return max(0.0, min(seconds, 30.0))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _read_remote_image(payload: dict[str, Any]) -> RemoteImage:
|
| 81 |
+
items = payload.get("data")
|
| 82 |
+
first = items[0] if isinstance(items, list) and items else None
|
| 83 |
+
url = first.get("url") if isinstance(first, dict) else None
|
| 84 |
+
if not isinstance(url, str) or not is_pollinations_remote_url(url):
|
| 85 |
+
raise ImageProviderError("Il provider non ha restituito un URL immagine sicuro.")
|
| 86 |
+
mime = first.get("media_type") if isinstance(first, dict) else None
|
| 87 |
+
revised_prompt = first.get("revised_prompt") if isinstance(first, dict) else None
|
| 88 |
+
return RemoteImage(
|
| 89 |
+
url=url,
|
| 90 |
+
mime_type=mime if isinstance(mime, str) and mime.startswith("image/") else "image/jpeg",
|
| 91 |
+
revised_prompt=revised_prompt if isinstance(revised_prompt, str) else None,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def _post_image_operation(path: str, payload: dict[str, Any], *, timeout_seconds: float) -> RemoteImage:
|
| 96 |
+
"""Esegue una richiesta provider con un solo retry per errori realmente transitori."""
|
| 97 |
+
headers = {
|
| 98 |
+
"Authorization": f"Bearer {_provider_key()}",
|
| 99 |
+
"Content-Type": "application/json",
|
| 100 |
+
"Accept": "application/json",
|
| 101 |
+
"User-Agent": "AgenteAI/3.4 image-provider",
|
| 102 |
+
}
|
| 103 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds), follow_redirects=False) as client:
|
| 104 |
+
for attempt in range(2):
|
| 105 |
+
try:
|
| 106 |
+
response = await client.post(f"{POLLINATIONS_BASE_URL}{path}", headers=headers, json=payload)
|
| 107 |
+
except httpx.TimeoutException as exc:
|
| 108 |
+
if attempt == 0:
|
| 109 |
+
await asyncio.sleep(0.5 + random.uniform(0.0, 0.25))
|
| 110 |
+
continue
|
| 111 |
+
raise ImageProviderError("Il provider immagini non ha risposto entro il tempo previsto.") from exc
|
| 112 |
+
except httpx.HTTPError as exc:
|
| 113 |
+
if attempt == 0:
|
| 114 |
+
await asyncio.sleep(0.5 + random.uniform(0.0, 0.25))
|
| 115 |
+
continue
|
| 116 |
+
raise ImageProviderError("Il provider immagini non Γ¨ raggiungibile.") from exc
|
| 117 |
+
|
| 118 |
+
if response.status_code == 200:
|
| 119 |
+
try:
|
| 120 |
+
return _read_remote_image(response.json())
|
| 121 |
+
except ValueError as exc:
|
| 122 |
+
raise ImageProviderError("Il provider ha restituito una risposta immagine non valida.") from exc
|
| 123 |
+
|
| 124 |
+
retry_after = _retry_after(response)
|
| 125 |
+
if response.status_code in _TRANSIENT_STATUS_CODES and attempt == 0:
|
| 126 |
+
await asyncio.sleep(retry_after if retry_after is not None else 0.75 + random.uniform(0.0, 0.25))
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
message_by_status = {
|
| 130 |
+
400: "La richiesta immagine non Γ¨ valida.",
|
| 131 |
+
401: "Il provider immagini non Γ¨ autenticato correttamente.",
|
| 132 |
+
402: "Il credito del provider immagini non Γ¨ disponibile.",
|
| 133 |
+
403: "Il provider immagini non autorizza questo modello o questa operazione.",
|
| 134 |
+
429: "Il provider immagini Γ¨ temporaneamente soggetto a rate limit.",
|
| 135 |
+
}
|
| 136 |
+
raise ImageProviderError(
|
| 137 |
+
message_by_status.get(response.status_code, "Il provider immagini ha restituito un errore."),
|
| 138 |
+
status_code=response.status_code,
|
| 139 |
+
retry_after=retry_after,
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
raise ImageProviderError("Il provider immagini non ha prodotto alcun risultato.")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
async def generate_pollinations_image(prompt: str, *, width: int = 1024, height: int = 1024) -> RemoteImage:
|
| 146 |
+
"""Genera una singola immagine remota, senza serializzare lβimmagine nella chat/SSE."""
|
| 147 |
+
safe_width = min(max(int(width), 256), 1024)
|
| 148 |
+
safe_height = min(max(int(height), 256), 1024)
|
| 149 |
+
return await _post_image_operation(
|
| 150 |
+
"/v1/images/generations",
|
| 151 |
+
{
|
| 152 |
+
"prompt": _bounded_prompt(prompt),
|
| 153 |
+
"model": "flux",
|
| 154 |
+
"n": 1,
|
| 155 |
+
"size": f"{safe_width}x{safe_height}",
|
| 156 |
+
"quality": "medium",
|
| 157 |
+
"response_format": "url",
|
| 158 |
+
"safe": True,
|
| 159 |
+
},
|
| 160 |
+
timeout_seconds=55,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
async def edit_pollinations_image(
|
| 165 |
+
prompt: str,
|
| 166 |
+
*,
|
| 167 |
+
source_bytes: bytes,
|
| 168 |
+
source_mime: str = "image/jpeg",
|
| 169 |
+
) -> RemoteImage:
|
| 170 |
+
"""Modifica un artefatto VFS inviandolo al provider come multipart/form-data."""
|
| 171 |
+
if not source_bytes or len(source_bytes) > 5 * 1024 * 1024:
|
| 172 |
+
raise ImageProviderError("Lβimmagine di origine deve avere una dimensione compresa tra 1 byte e 5 MB.", status_code=400)
|
| 173 |
+
if source_mime not in {"image/jpeg", "image/png", "image/webp"}:
|
| 174 |
+
raise ImageProviderError("Il formato dellβimmagine di origine non Γ¨ supportato.", status_code=400)
|
| 175 |
+
|
| 176 |
+
extension = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}[source_mime]
|
| 177 |
+
data = {
|
| 178 |
+
"prompt": _bounded_prompt(prompt),
|
| 179 |
+
"model": "flux",
|
| 180 |
+
"n": "1",
|
| 181 |
+
"response_format": "url",
|
| 182 |
+
"safe": "true",
|
| 183 |
+
}
|
| 184 |
+
headers = {
|
| 185 |
+
"Authorization": f"Bearer {_provider_key()}",
|
| 186 |
+
"Accept": "application/json",
|
| 187 |
+
"User-Agent": "AgenteAI/3.4 image-provider",
|
| 188 |
+
}
|
| 189 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(70), follow_redirects=False) as client:
|
| 190 |
+
for attempt in range(2):
|
| 191 |
+
try:
|
| 192 |
+
response = await client.post(
|
| 193 |
+
f"{POLLINATIONS_BASE_URL}/v1/images/edits",
|
| 194 |
+
headers=headers,
|
| 195 |
+
data=data,
|
| 196 |
+
files={"image": (f"source.{extension}", source_bytes, source_mime)},
|
| 197 |
+
)
|
| 198 |
+
except httpx.TimeoutException as exc:
|
| 199 |
+
if attempt == 0:
|
| 200 |
+
await asyncio.sleep(0.5 + random.uniform(0.0, 0.25))
|
| 201 |
+
continue
|
| 202 |
+
raise ImageProviderError("Il provider immagini non ha risposto entro il tempo previsto.") from exc
|
| 203 |
+
except httpx.HTTPError as exc:
|
| 204 |
+
if attempt == 0:
|
| 205 |
+
await asyncio.sleep(0.5 + random.uniform(0.0, 0.25))
|
| 206 |
+
continue
|
| 207 |
+
raise ImageProviderError("Il provider immagini non Γ¨ raggiungibile.") from exc
|
| 208 |
+
|
| 209 |
+
if response.status_code == 200:
|
| 210 |
+
try:
|
| 211 |
+
return _read_remote_image(response.json())
|
| 212 |
+
except ValueError as exc:
|
| 213 |
+
raise ImageProviderError("Il provider ha restituito una risposta immagine non valida.") from exc
|
| 214 |
+
retry_after = _retry_after(response)
|
| 215 |
+
if response.status_code in _TRANSIENT_STATUS_CODES and attempt == 0:
|
| 216 |
+
await asyncio.sleep(retry_after if retry_after is not None else 0.75 + random.uniform(0.0, 0.25))
|
| 217 |
+
continue
|
| 218 |
+
message_by_status = {
|
| 219 |
+
400: "La richiesta di modifica immagine non Γ¨ valida.",
|
| 220 |
+
401: "Il provider immagini non Γ¨ autenticato correttamente.",
|
| 221 |
+
402: "Il credito del provider immagini non Γ¨ disponibile.",
|
| 222 |
+
403: "Il provider immagini non autorizza questa modifica.",
|
| 223 |
+
429: "Il provider immagini Γ¨ temporaneamente soggetto a rate limit.",
|
| 224 |
+
}
|
| 225 |
+
raise ImageProviderError(message_by_status.get(response.status_code, "Il provider immagini ha restituito un errore."), status_code=response.status_code, retry_after=retry_after)
|
| 226 |
+
raise ImageProviderError("Il provider immagini non ha prodotto alcun risultato.")
|
api/vision.py
CHANGED
|
@@ -26,6 +26,7 @@ from huggingface_hub import InferenceClient
|
|
| 26 |
from fastapi import APIRouter, Depends
|
| 27 |
from .auth_guard import require_role, AuthRole
|
| 28 |
from pydantic import BaseModel
|
|
|
|
| 29 |
|
| 30 |
router = APIRouter(prefix="/api/vision", tags=["vision"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 31 |
_logger = logging.getLogger("vision")
|
|
@@ -76,6 +77,7 @@ class AnalyzeImageRequest(BaseModel):
|
|
| 76 |
class EditImageRequest(BaseModel):
|
| 77 |
prompt: str
|
| 78 |
base64_image: str
|
|
|
|
| 79 |
negative_prompt: str = ""
|
| 80 |
steps: int = 5
|
| 81 |
|
|
@@ -99,58 +101,35 @@ async def generate_image(req: GenerateImageRequest):
|
|
| 99 |
width = min(max(req.width, 256), 1024)
|
| 100 |
height = min(max(req.height, 256), 1024)
|
| 101 |
|
| 102 |
-
def _run_generation():
|
| 103 |
-
client = InferenceClient(token=os.getenv("HF_TOKEN"), provider="auto", timeout=90)
|
| 104 |
-
return client.text_to_image(
|
| 105 |
-
prompt=prompt,
|
| 106 |
-
model=model_id,
|
| 107 |
-
negative_prompt=req.negative_prompt[:200] if req.negative_prompt else None,
|
| 108 |
-
num_inference_steps=steps,
|
| 109 |
-
width=width,
|
| 110 |
-
height=height,
|
| 111 |
-
)
|
| 112 |
-
|
| 113 |
try:
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
except
|
| 119 |
-
return {"ok": False, "error": "
|
| 120 |
-
|
| 121 |
-
_logger.warning("HF image generation failed: %s", type(e).__name__)
|
| 122 |
-
return {"ok": False, "error": f"HF image generation unavailable: {str(e)[:300]}"}
|
| 123 |
|
| 124 |
|
| 125 |
# βββ /edit ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
|
| 127 |
@router.post("/edit")
|
| 128 |
async def edit_image(req: EditImageRequest):
|
| 129 |
-
"""Modifica un
|
| 130 |
try:
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
edited = await asyncio.to_thread(_run_edit)
|
| 146 |
-
output = io.BytesIO()
|
| 147 |
-
edited.save(output, format="PNG")
|
| 148 |
-
return {"ok": True, "image_b64": base64.b64encode(output.getvalue()).decode(), "mime": "image/png", "model": "FLUX.1-Kontext-dev"}
|
| 149 |
-
except TimeoutError:
|
| 150 |
-
return {"ok": False, "error": "Timeout 120s β modello image-to-image in cold-start."}
|
| 151 |
-
except Exception as e:
|
| 152 |
-
_logger.warning("HF image edit failed: %s", type(e).__name__)
|
| 153 |
-
return {"ok": False, "error": f"HF image edit unavailable: {str(e)[:300]}"}
|
| 154 |
|
| 155 |
|
| 156 |
# βββ /analyze βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½β
|
|
|
|
| 26 |
from fastapi import APIRouter, Depends
|
| 27 |
from .auth_guard import require_role, AuthRole
|
| 28 |
from pydantic import BaseModel
|
| 29 |
+
from .image_provider import ImageProviderError, edit_pollinations_image, generate_pollinations_image
|
| 30 |
|
| 31 |
router = APIRouter(prefix="/api/vision", tags=["vision"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 32 |
_logger = logging.getLogger("vision")
|
|
|
|
| 77 |
class EditImageRequest(BaseModel):
|
| 78 |
prompt: str
|
| 79 |
base64_image: str
|
| 80 |
+
mime_type: str = "image/jpeg"
|
| 81 |
negative_prompt: str = ""
|
| 82 |
steps: int = 5
|
| 83 |
|
|
|
|
| 101 |
width = min(max(req.width, 256), 1024)
|
| 102 |
height = min(max(req.height, 256), 1024)
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
try:
|
| 105 |
+
remote = await generate_pollinations_image(prompt, width=width, height=height)
|
| 106 |
+
return {"ok": True, "image_url": remote.url, "mime": remote.mime_type,
|
| 107 |
+
"provider": remote.provider, "prompt": req.prompt[:100],
|
| 108 |
+
"revised_prompt": remote.revised_prompt}
|
| 109 |
+
except ImageProviderError as exc:
|
| 110 |
+
return {"ok": False, "error": str(exc), "status_code": exc.status_code,
|
| 111 |
+
"retry_after": exc.retry_after}
|
|
|
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
# βββ /edit ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
|
| 116 |
@router.post("/edit")
|
| 117 |
async def edit_image(req: EditImageRequest):
|
| 118 |
+
"""Modifica un file immagine VFS con il provider gratuito server-side."""
|
| 119 |
try:
|
| 120 |
+
raw = req.base64_image.strip()
|
| 121 |
+
if raw.startswith("data:"):
|
| 122 |
+
header, _, raw = raw.partition(",")
|
| 123 |
+
req.mime_type = header[5:].split(";", 1)[0] or req.mime_type
|
| 124 |
+
source = base64.b64decode(raw, validate=True)
|
| 125 |
+
remote = await edit_pollinations_image(req.prompt, source_bytes=source, source_mime=req.mime_type)
|
| 126 |
+
return {"ok": True, "image_url": remote.url, "mime": remote.mime_type,
|
| 127 |
+
"provider": remote.provider, "revised_prompt": remote.revised_prompt}
|
| 128 |
+
except (ValueError, base64.binascii.Error):
|
| 129 |
+
return {"ok": False, "error": "Lβimmagine di origine non Γ¨ codificata correttamente.", "status_code": 400}
|
| 130 |
+
except ImageProviderError as exc:
|
| 131 |
+
return {"ok": False, "error": str(exc), "status_code": exc.status_code,
|
| 132 |
+
"retry_after": exc.retry_after}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
|
| 135 |
# βββ /analyze βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½β
|
tests/test_image_provider.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from api.image_provider import ImageProviderError, _read_remote_image, is_pollinations_remote_url
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_accepts_only_https_pollinations_urls() -> None:
|
| 9 |
+
assert is_pollinations_remote_url("https://image.pollinations.ai/prompt/cat?seed=1")
|
| 10 |
+
assert is_pollinations_remote_url("https://gen.pollinations.ai/image/cat")
|
| 11 |
+
assert not is_pollinations_remote_url("http://image.pollinations.ai/prompt/cat")
|
| 12 |
+
assert not is_pollinations_remote_url("https://example.com/image.jpg")
|
| 13 |
+
assert not is_pollinations_remote_url("data:image/jpeg;base64,AA==")
|
| 14 |
+
assert not is_pollinations_remote_url("https://image.pollinations.ai.evil.example/image")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_parses_safe_remote_result() -> None:
|
| 18 |
+
image = _read_remote_image({
|
| 19 |
+
"data": [{
|
| 20 |
+
"url": "https://image.pollinations.ai/prompt/a-safe-cat?seed=7",
|
| 21 |
+
"media_type": "image/png",
|
| 22 |
+
"revised_prompt": "a safe cat",
|
| 23 |
+
}]
|
| 24 |
+
})
|
| 25 |
+
assert image.url.startswith("https://image.pollinations.ai/")
|
| 26 |
+
assert image.mime_type == "image/png"
|
| 27 |
+
assert image.revised_prompt == "a safe cat"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_rejects_missing_or_untrusted_remote_result() -> None:
|
| 31 |
+
with pytest.raises(ImageProviderError):
|
| 32 |
+
_read_remote_image({"data": []})
|
| 33 |
+
with pytest.raises(ImageProviderError):
|
| 34 |
+
_read_remote_image({"data": [{"url": "https://example.com/image.jpg"}]})
|