Rewrite ai_vision.py: simplified single-call OpenRouter, no retries, 180s timeout
CI / Lint & Format (push) Failing after 12s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped

v3 rewrite:
- _call_openrouter(): single HTTP call, never raises, always returns dict
- Removed Gemini SDK, _retry_with_backoff, _generate dispatcher
- Image resize (max 1024px JPEG) stays
- Detailed logging: ai.call.start → ai.call.response → ai.call.success/timeout
- All error paths return structured dicts, never propagate exceptions
This commit is contained in:
2026-06-26 07:44:44 -04:00
parent cd2a2efbd5
commit 73a5e401d2
+294 -344
View File
@@ -1,23 +1,21 @@
"""
Service AI Vision — description, classification et tags via Google Gemini ou OpenRouter.
Service AI Vision — analyse d'images via OpenRouter.
Résilience :
- Timeout configurable (AI_REQUEST_TIMEOUT)
- Retry avec backoff exponentiel (AI_MAX_RETRIES)
- Client Gemini non-singleton (recréé si la clé change)
Design simplifié (v3):
- Un seul appel HTTP, pas de retry (ARQ gère les retries au niveau job)
- Timeout généreux (180s) pour les modèles de vision lents
- Logging détaillé à chaque étape pour diagnostic
- Redimensionnement Pillow avant envoi (max 1024px JPEG)
"""
import asyncio
import base64
import json
import logging
import re
import base64
import time
import httpx
from pathlib import Path
from typing import Optional, Tuple
from typing import Optional
from google import genai
from google.genai import types
import httpx
from app.config import settings
from app.services.storage_backend import get_storage_backend
@@ -25,293 +23,237 @@ from app.services.runtime_config import runtime_ai_config
logger = logging.getLogger(__name__)
# Cache du client Gemini avec TTL
_client: Optional[genai.Client] = None
_client_api_key: Optional[str] = None
def _get_client() -> genai.Client:
global _client, _client_api_key
current_key = settings.GEMINI_API_KEY
if _client is None or _client_api_key != current_key:
_client = genai.Client(api_key=current_key)
_client_api_key = current_key
return _client
# ─────────────────────────────────────────────────────────────
# Lecture et redimensionnement d'image
# ─────────────────────────────────────────────────────────────
async def _read_image(file_path: str) -> tuple[bytes, str]:
"""Lit l'image via le StorageBackend et la redimensionne pour l'AI (max 1024px)."""
path = Path(file_path)
suffix = path.suffix.lower()
"""Lit et redimensionne l'image pour l'API AI."""
backend = get_storage_backend()
data = await backend.get_bytes(file_path)
# Redimensionner pour éviter les timeouts AI sur les grandes images
try:
from PIL import Image as PILImage
import io as pil_io
original_size = len(data)
img = PILImage.open(pil_io.BytesIO(data))
max_dim = 1024
if img.width > max_dim or img.height > max_dim:
img.thumbnail((max_dim, max_dim), PILImage.Resampling.LANCZOS)
buf = pil_io.BytesIO()
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
buf = pil_io.BytesIO()
img.save(buf, format="JPEG", quality=85)
data = buf.getvalue()
logger.info("ai.image_resized", extra={
"file": path.name,
"original_bytes": original_size,
"resized_bytes": len(data),
})
logger.info(
"ai.image_resized",
extra={
"file": Path(file_path).name,
"original_bytes": original_size,
"resized_bytes": len(data),
"dimensions": f"{img.width}x{img.height}",
},
)
return data, "image/jpeg"
except Exception:
pass # Si Pillow échoue, on utilise l'image originale
except Exception as e:
logger.warning("ai.image_resize_failed", extra={"error": str(e)[:100]})
# Fallback: image originale
suffix = Path(file_path).suffix.lower()
mime_map = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".gif": "image/gif", ".webp": "image/webp",
}
media_type = mime_map.get(suffix, "image/jpeg")
return data, media_type
def _extract_json(text: str) -> Optional[dict]:
cleaned = re.sub(r"```json\s*|```\s*", "", (text or "")).strip()
json_match = re.search(r"\{.*\}", cleaned, re.DOTALL)
if not json_match:
return None
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
return None
def _usage_tokens_gemini(response) -> tuple[Optional[int], Optional[int]]:
usage = getattr(response, "usage_metadata", None)
if not usage:
return None, None
prompt_tokens = getattr(usage, "prompt_token_count", None)
output_tokens = getattr(usage, "candidates_token_count", None)
return prompt_tokens, output_tokens
async def _retry_with_backoff(fn, *args, max_retries=None, **kwargs):
retries = max_retries if max_retries is not None else settings.AI_MAX_RETRIES
last_error = None
for attempt in range(retries + 1):
try:
return await fn(*args, **kwargs)
except asyncio.TimeoutError:
last_error = "timeout"
wait = 2 ** attempt
logger.warning("ai.retry.timeout", extra={
"attempt": attempt + 1, "max_retries": retries, "wait_s": wait,
})
except Exception as e:
last_error = str(e)[:200]
wait = 2 ** attempt
logger.warning("ai.retry.error", extra={
"attempt": attempt + 1, "max_retries": retries,
"wait_s": wait, "error": str(e)[:200],
})
if attempt < retries:
await asyncio.sleep(wait)
raise Exception(f"AI request failed after {retries + 1} attempts: {last_error}")
return data, mime_map.get(suffix, "image/jpeg")
# ─────────────────────────────────────────────────────────────
# Google Gemini
# Appel unique à OpenRouter
# ─────────────────────────────────────────────────────────────
async def _generate_gemini(
async def _call_openrouter(
*,
model: str,
prompt: str,
image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None,
media_type: str = "image/jpeg",
max_tokens: int = 1024,
model_override: Optional[str] = None,
timeout_secs: int = 180,
) -> dict:
if not settings.GEMINI_API_KEY:
return {"text": None, "usage": (None, None)}
"""
Appel unique à OpenRouter Chat Completions API.
Retourne toujours un dict, jamais ne lève d'exception.
"""
api_key = settings.OPENROUTER_API_KEY
if not api_key:
return {"ok": False, "error": "OPENROUTER_API_KEY non configurée"}
client = _get_client()
contents = []
if image_bytes and media_type:
contents.append(types.Part.from_bytes(data=image_bytes, mime_type=media_type))
contents.append(prompt)
model = model_override or runtime_ai_config.model or settings.GEMINI_MODEL
async def _call():
return await asyncio.wait_for(
asyncio.to_thread(
client.models.generate_content,
model=model,
contents=contents,
config=types.GenerateContentConfig(
max_output_tokens=max_tokens,
response_mime_type="application/json",
),
),
timeout=settings.AI_REQUEST_TIMEOUT,
)
try:
response = await _retry_with_backoff(_call)
usage = _usage_tokens_gemini(response)
return {"text": getattr(response, "text", ""), "usage": usage}
except Exception as e:
logger.error("ai.gemini.error", extra={
"model": model, "error_type": type(e).__name__, "error": str(e)[:300],
})
return {"text": None, "usage": (None, None), "error": str(e)[:300]}
# ─────────────────────────────────────────────────────────────
# OpenRouter
# ─────────────────────────────────────────────────────────────
async def _generate_openrouter(
prompt: str,
image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None,
max_tokens: int = 1024,
model_override: Optional[str] = None,
) -> dict:
if not settings.OPENROUTER_API_KEY:
return {"text": None, "usage": (None, None)}
model = model_override or runtime_ai_config.model or settings.OPENROUTER_MODEL
headers = {
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "imago-pipeline",
"X-Title": settings.APP_NAME,
}
messages = []
content_payload = [{"type": "text", "text": prompt}]
if image_bytes and media_type:
b64_img = base64.b64encode(image_bytes).decode("utf-8")
content_payload.append({
# Construire le payload
content = [{"type": "text", "text": prompt}]
if image_bytes:
b64 = base64.b64encode(image_bytes).decode()
content.append({
"type": "image_url",
"image_url": {"url": f"data:{media_type};base64,{b64_img}"}
"image_url": {"url": f"data:{media_type};base64,{b64}"},
})
messages.append({"role": "user", "content": content_payload})
payload = {
"model": model,
"messages": messages,
"messages": [{"role": "user", "content": content}],
"max_tokens": max_tokens,
"response_format": {"type": "json_object"}
"response_format": {"type": "json_object"},
}
timeout_secs = settings.AI_REQUEST_TIMEOUT
logger.info("ai.openrouter.request", extra={
"model": model, "timeout_s": timeout_secs,
"image_bytes": len(image_bytes) if image_bytes else 0,
})
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "imago",
"X-Title": settings.APP_NAME,
}
async def _call():
t0 = time.time()
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(timeout_secs, connect=15.0)
) as client:
response = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
json=payload,
headers=headers,
)
elapsed = time.time() - t0
logger.info("ai.openrouter.response", extra={
"status": response.status_code, "elapsed_s": round(elapsed, 1),
})
response.raise_for_status()
return response.json()
except httpx.TimeoutException as e:
elapsed = time.time() - t0
logger.error("ai.openrouter.timeout", extra={
"elapsed_s": round(elapsed, 1),
"timeout_setting": timeout_secs,
"detail": str(e),
})
raise
t_start = time.monotonic()
logger.info(
"ai.call.start",
extra={
"model": model,
"timeout_s": timeout_secs,
"image_bytes": len(image_bytes) if image_bytes else 0,
},
)
try:
data = await _retry_with_backoff(_call)
async with httpx.AsyncClient(
timeout=httpx.Timeout(timeout_secs, connect=15.0)
) as client:
response = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
json=payload,
headers=headers,
)
elapsed = time.monotonic() - t_start
logger.info(
"ai.call.response",
extra={
"status": response.status_code,
"elapsed_s": round(elapsed, 1),
},
)
if response.status_code != 200:
error_body = response.text[:500]
logger.error(
"ai.call.http_error",
extra={
"status": response.status_code,
"elapsed_s": round(elapsed, 1),
"body": error_body,
},
)
return {
"ok": False,
"error": f"HTTP {response.status_code}: {error_body}",
"elapsed_s": round(elapsed, 1),
}
data = response.json()
# Extraire le texte de la réponse
text = ""
if "choices" in data and len(data["choices"]) > 0:
text = data["choices"][0]["message"]["content"]
usage_data = data.get("usage", {})
prompt_tokens = usage_data.get("prompt_tokens")
output_tokens = usage_data.get("completion_tokens")
return {"text": text, "usage": (prompt_tokens, output_tokens)}
text = data["choices"][0].get("message", {}).get("content", "")
# Usage tokens
usage = data.get("usage", {})
prompt_tokens = usage.get("prompt_tokens")
completion_tokens = usage.get("completion_tokens")
logger.info(
"ai.call.success",
extra={
"model": model,
"elapsed_s": round(elapsed, 1),
"response_chars": len(text),
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
},
)
return {
"ok": True,
"text": text,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"elapsed_s": round(elapsed, 1),
}
except httpx.TimeoutException as e:
elapsed = time.monotonic() - t_start
logger.error(
"ai.call.timeout",
extra={
"model": model,
"elapsed_s": round(elapsed, 1),
"timeout_setting": timeout_secs,
"error": str(e)[:200],
},
)
return {
"ok": False,
"error": f"Timeout après {round(elapsed,1)}s (limite: {timeout_secs}s)",
"elapsed_s": round(elapsed, 1),
}
except Exception as e:
logger.error("ai.openrouter.final_error", extra={
"model": model, "error_type": type(e).__name__, "error": str(e)[:300],
})
return {"text": None, "usage": (None, None), "error": str(e)[:300]}
# ─────────────────────────────────────────────────────────────
# Dispatcher
# ─────────────────────────────────────────────────────────────
async def _generate(
prompt: str,
image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None,
max_tokens: int = 1024,
provider_override: Optional[str] = None,
model_override: Optional[str] = None,
) -> dict:
provider = provider_override or runtime_ai_config.provider or settings.AI_PROVIDER.lower()
logger.info("ai.generate", extra={"provider": provider})
if provider == "openrouter":
return await _generate_openrouter(
prompt, image_bytes, media_type, max_tokens,
model_override=model_override,
)
else:
return await _generate_gemini(
prompt, image_bytes, media_type, max_tokens,
model_override=model_override,
elapsed = time.monotonic() - t_start
logger.error(
"ai.call.exception",
extra={
"model": model,
"elapsed_s": round(elapsed, 1),
"exception_type": type(e).__name__,
"error": str(e)[:300],
},
)
return {
"ok": False,
"error": f"{type(e).__name__}: {str(e)[:200]}",
"elapsed_s": round(elapsed, 1),
}
# ─────────────────────────────────────────────────────────────
# Fonctions publiques
# Prompt builder
# ─────────────────────────────────────────────────────────────
def _build_prompt(ocr_hint: Optional[str], language: str) -> str:
ocr_section = ""
if ocr_hint and len(ocr_hint.strip()) > 5:
ocr_section = f"""
Texte détecté dans l'image par OCR (utilise-le pour enrichir ta réponse) :
\"\"\"
{ocr_hint[:500]}
\"\"\"
"""
return f"""Analyse cette image avec précision et retourne UNIQUEMENT un objet JSON valide avec ces champs :
ocr_section = (
f'\nTexte détecté dans l\'image par OCR : """{ocr_hint[:300]}"""'
)
return f"""Analyse cette image et retourne UNIQUEMENT un objet JSON:
{{
"description": "Description complète et détaillée en {language}, 2-4 phrases. Décris le sujet principal, le contexte, les couleurs, l'ambiance.",
"tags": ["tag1", "tag2", "tag3"],
"description": "Description détaillée en {language}, 2-4 phrases.",
"tags": ["tag1", "tag2", "tag3", ...],
"confidence": 0.95
}}
Règles pour les tags :
- Entre {settings.AI_TAGS_MIN} et {settings.AI_TAGS_MAX} tags
- En minuscules, sans espaces (utiliser des tirets si nécessaire)
- Couvrir : sujet principal, type d'image, couleurs dominantes, style, contexte
- Exemples : portrait, paysage, architecture, nature, nourriture, texte, document, animal, sport, technologie, intérieur, extérieur
Règles:
- {settings.AI_TAGS_MIN} à {settings.AI_TAGS_MAX} tags en minuscules, sans espaces
- Décris le sujet, contexte, couleurs, ambiance
{ocr_section}
Réponds UNIQUEMENT avec le JSON, sans texte avant ou après, sans balises markdown."""
Réponds UNIQUEMENT avec le JSON, pas de markdown."""
# ─────────────────────────────────────────────────────────────
# Fonction principale — analyse d'image
# ─────────────────────────────────────────────────────────────
async def analyze_image(
file_path: str,
@@ -320,168 +262,176 @@ async def analyze_image(
provider_override: Optional[str] = None,
model_override: Optional[str] = None,
) -> dict:
if not settings.AI_ENABLED:
return {}
"""
Analyse une image avec l'AI (description + tags).
active_provider = provider_override or runtime_ai_config.provider or settings.AI_PROVIDER
active_model = model_override or runtime_ai_config.model or (
settings.OPENROUTER_MODEL if active_provider == "openrouter"
else settings.GEMINI_MODEL
Priorité du modèle:
1. model_override (paramètre explicite)
2. runtime_ai_config.model (override admin live)
3. settings.OPENROUTER_MODEL (.env)
"""
if not settings.AI_ENABLED:
return {"description": None, "tags": [], "confidence": None, "model": None}
model = (
model_override
or runtime_ai_config.model
or settings.OPENROUTER_MODEL
)
# Résultat par défaut
result = {
"description": None, "tags": [], "confidence": None,
"model": active_model, "prompt_tokens": None, "output_tokens": None,
"description": None,
"tags": [],
"confidence": None,
"model": model,
"prompt_tokens": None,
"output_tokens": None,
}
# Lire et redimensionner l'image
try:
image_bytes, media_type = await _read_image(file_path)
prompt = _build_prompt(ocr_hint, language)
response = await _generate(
prompt=prompt, image_bytes=image_bytes, media_type=media_type,
max_tokens=settings.GEMINI_MAX_TOKENS,
provider_override=provider_override, model_override=model_override,
)
text = response.get("text")
result["prompt_tokens"], result["output_tokens"] = response.get("usage")
if text:
parsed = _extract_json(text)
if parsed:
result["description"] = parsed.get("description")
result["tags"] = parsed.get("tags", [])
result["confidence"] = parsed.get("confidence")
else:
logger.warning("ai.vision.json_parse_failed", extra={"raw": text[:100]})
if response.get("error"):
logger.error("ai.vision.provider_error", extra={"error": response["error"]})
except Exception as e:
logger.error("ai.vision.unexpected_error", extra={"error": str(e)})
logger.error("ai.read_image_failed", extra={"file": file_path, "error": str(e)})
return result
# Construire le prompt
prompt = _build_prompt(ocr_hint, language)
# Appeler l'API
response = await _call_openrouter(
model=model,
prompt=prompt,
image_bytes=image_bytes,
media_type=media_type,
max_tokens=settings.GEMINI_MAX_TOKENS,
timeout_secs=settings.AI_REQUEST_TIMEOUT,
)
if not response.get("ok"):
logger.error(
"ai.analysis_failed",
extra={"model": model, "error": response.get("error", "unknown")},
)
return result
# Parser la réponse JSON
text = response.get("text", "")
result["prompt_tokens"] = response.get("prompt_tokens")
result["output_tokens"] = response.get("completion_tokens")
if text:
parsed = _extract_json(text)
if parsed:
result["description"] = parsed.get("description")
result["tags"] = parsed.get("tags", [])
result["confidence"] = parsed.get("confidence")
return result
def _extract_json(text: str) -> Optional[dict]:
"""Extrait un objet JSON d'une réponse textuelle."""
cleaned = re.sub(r"```json\s*|```\s*", "", text).strip()
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group())
except json.JSONDecodeError:
return None
# ─────────────────────────────────────────────────────────────
# Fonctions auxiliaires (OCR fallback, résumé, tâches)
# ─────────────────────────────────────────────────────────────
async def extract_text_with_ai(file_path: str) -> dict:
"""OCR fallback via AI."""
result = {
"text": None, "has_text": False, "language": "unknown",
"confidence": 0.0, "method": f"ai-{settings.AI_PROVIDER}"
"confidence": 0.0, "method": f"ai-openrouter",
}
if not settings.AI_ENABLED:
return result
logger.info("ai.ocr.fallback_start", extra={"file": Path(file_path).name})
model = runtime_ai_config.model or settings.OPENROUTER_MODEL
try:
image_bytes, media_type = await _read_image(file_path)
prompt = """Agis comme un moteur OCR avancé.
Extrais TOUT le texte visible dans cette image.
Retourne UNIQUEMENT un objet JSON :
{
"text": "Le texte complet extrait ici...",
"language": "fr" (code langue ISO 2 lettres, ex: fr, en, es),
"confidence": 0.9 (estimation confiance 0.0 à 1.0)
}
Si aucun texte n'est visible, retourne : {"text": "", "has_text": false}
"""
response = await _generate(
prompt=prompt, image_bytes=image_bytes, media_type=media_type,
max_tokens=1024
)
except Exception:
return result
text = response.get("text")
response = await _call_openrouter(
model=model,
prompt="""Extrais TOUT le texte visible dans cette image.
Retourne UNIQUEMENT un objet JSON:
{"text": "texte extrait...", "language": "fr", "confidence": 0.9}
Si aucun texte: {"text": "", "has_text": false}""",
image_bytes=image_bytes,
media_type=media_type,
max_tokens=1024,
)
if response.get("ok"):
text = response.get("text", "")
if text:
parsed = _extract_json(text)
if parsed:
extracted = parsed.get("text", "").strip()
result["text"] = extracted
result["has_text"] = bool(extracted) or parsed.get("has_text", False)
result["has_text"] = bool(extracted)
result["language"] = parsed.get("language", "unknown")
result["confidence"] = parsed.get("confidence", 0.0)
logger.info("ai.ocr.success", extra={"chars": len(extracted)})
else:
logger.warning("ai.ocr.json_parse_failed")
else:
logger.info("ai.ocr.empty_response")
except Exception as e:
logger.error("ai.ocr.error", extra={"error": str(e)})
return result
async def summarize_url(url: str, content: str, language: str = "français") -> dict:
result = {
"summary": "", "tags": [], "model": settings.AI_PROVIDER,
"prompt_tokens": None, "output_tokens": None,
}
"""Résumé AI d'une URL."""
result = {"summary": "", "tags": [], "model": "openrouter", "prompt_tokens": None, "output_tokens": None}
if not settings.AI_ENABLED:
return result
prompt = f"""Tu reçois le contenu d'une page web. Génère un résumé et des tags en {language}.
URL : {url}
Contenu :
\"\"\"
{content[:3000]}
\"\"\"
Retourne UNIQUEMENT ce JSON :
{{
"summary": "Résumé clair en 3-5 phrases en {language}",
"tags": ["tag1", "tag2", "tag3"]
}}"""
try:
response = await _generate(prompt=prompt, max_tokens=settings.GEMINI_MAX_TOKENS)
text = response.get("text")
model = runtime_ai_config.model or settings.OPENROUTER_MODEL
response = await _call_openrouter(
model=model,
prompt=f"""URL: {url}\n\nContenu:\n\"\"\"\n{content[:3000]}\n\"\"\"\n\nRésume en {language} et retourne UNIQUEMENT:\n{{"summary": "...", "tags": ["..."]}}""",
max_tokens=settings.GEMINI_MAX_TOKENS,
)
if response.get("ok"):
text = response.get("text", "")
if text:
parsed = _extract_json(text)
if parsed:
result["summary"] = parsed.get("summary", "")
result["tags"] = parsed.get("tags", [])
result["prompt_tokens"], result["output_tokens"] = response.get("usage")
except Exception as e:
logger.error("ai.summarize_url.error", extra={"error": str(e)})
result["prompt_tokens"] = response.get("prompt_tokens")
result["output_tokens"] = response.get("completion_tokens")
return result
async def draft_task(description: str, context: Optional[str], language: str = "français") -> dict:
result = {
"title": "", "description": "", "steps": [],
"estimated_time": None, "priority": None,
"model": settings.AI_PROVIDER, "prompt_tokens": None, "output_tokens": None,
}
"""Génération AI d'une tâche."""
result = {"title": "", "description": "", "steps": [], "estimated_time": None, "priority": None,
"model": "openrouter", "prompt_tokens": None, "output_tokens": None}
if not settings.AI_ENABLED:
return result
ctx_section = f"\nContexte : {context}" if context else ""
prompt = f"""Tu es un assistant de gestion de tâches. Génère une tâche structurée en {language}.
Description : {description}{ctx_section}
Retourne UNIQUEMENT ce JSON :
{{
"title": "Titre court et actionnable",
"description": "Description complète de la tâche",
"steps": ["Étape 1", "Étape 2", "Étape 3"],
"estimated_time": "30 minutes",
"priority": "haute|moyenne|basse"
}}"""
try:
response = await _generate(prompt=prompt, max_tokens=settings.GEMINI_MAX_TOKENS)
text = response.get("text")
ctx = f"\nContexte: {context}" if context else ""
model = runtime_ai_config.model or settings.OPENROUTER_MODEL
response = await _call_openrouter(
model=model,
prompt=f"Crée une tâche structurée en {language}.\nDescription: {description}{ctx}\n\nRetourne UNIQUEMENT:\n{{'title':'...','description':'...','steps':['...'],'estimated_time':'...','priority':'...'}}",
max_tokens=settings.GEMINI_MAX_TOKENS,
)
if response.get("ok"):
text = response.get("text", "")
if text:
parsed = _extract_json(text)
if parsed:
result.update(parsed)
result["prompt_tokens"], result["output_tokens"] = response.get("usage")
except Exception as e:
logger.error("ai.draft_task.error", extra={"error": str(e)})
result["prompt_tokens"] = response.get("prompt_tokens")
result["output_tokens"] = response.get("completion_tokens")
return result