Baida07 commited on
Commit
16b8da8
Β·
1 Parent(s): e0c905b

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 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
- _img_seed = sum(ord(char) for char in _img_prompt) % 9999 + 1
370
- img_url = (
371
- f"https://image.pollinations.ai/prompt/{quote(_img_prompt, safe='')}"
372
- f"?width=512&height=512&seed={_img_seed}&nologo=true&enhance=true"
373
- )
374
- # L'URL viene materializzato dal client nel VFS come data URI JPEG
375
- # durante il commit atomico del task. Il nome Γ¨ derivato dal prompt
376
- # normalizzato: Γ¨ sicuro, ripetibile e non contiene input utente.
 
 
 
 
 
 
 
 
 
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": "image/jpeg",
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
- if (_source_url.startswith('https://image.pollinations.ai/') and
 
 
 
 
 
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
- generated = await asyncio.to_thread(_run_generation)
115
- output = io.BytesIO()
116
- generated.save(output, format="PNG")
117
- return {"ok": True, "image_b64": base64.b64encode(output.getvalue()).decode(), "mime": "image/png", "model": model_id, "prompt": req.prompt[:100]}
118
- except TimeoutError:
119
- return {"ok": False, "error": "Timeout 90s β€” modello HF in cold-start. Riprova tra 30s."}
120
- except Exception as e:
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’immagine con un provider Hugging Face selezionato automaticamente."""
130
  try:
131
- source = base64.b64decode(req.base64_image)
132
- prompt = req.prompt.strip()[:400]
133
- steps = min(max(req.steps, 1), 8)
134
-
135
- def _run_edit():
136
- client = InferenceClient(token=os.getenv("HF_TOKEN"), provider="auto", timeout=120)
137
- return client.image_to_image(
138
- image=source,
139
- prompt=prompt,
140
- model="black-forest-labs/FLUX.1-Kontext-dev",
141
- negative_prompt=req.negative_prompt[:200] if req.negative_prompt else None,
142
- num_inference_steps=steps,
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"}]})