diff --git a/src/backend/routers/avatar.py b/src/backend/routers/avatar.py index 6a5eadaa2..dae371d62 100644 --- a/src/backend/routers/avatar.py +++ b/src/backend/routers/avatar.py @@ -27,6 +27,47 @@ AVATAR_DIR = Path("/data/avatars") + +# #957: Map image-generation error_kind → (HTTP status, user-facing detail). +# Keeps the Gemini-internal error strings out of the dialog; gives the +# operator/owner an actionable message per failure mode. +_AVATAR_ERROR_HTTP = { + "not_configured": ( + 501, + "Avatar generation isn't configured on this server. Ask an admin " + "to set GEMINI_API_KEY.", + ), + "invalid_input": (400, "Avatar request was rejected by the image service."), + "safety_filter": ( + 422, + "The prompt was blocked by the image service's safety filters. " + "Try rephrasing and avoid descriptions of real people, sensitive " + "content, or trademarked characters.", + ), + "rate_limited": ( + 429, + "Image generation is rate-limited right now. Wait a minute and retry.", + ), + "upstream_error": ( + 502, + "Image generation service returned an error. This is usually transient — please retry.", + ), + "timeout": ( + 504, + "Image generation timed out. The model can be slow under load — please retry.", + ), + "unknown": ( + 422, + "Avatar generation failed. Check server logs for details.", + ), +} + + +def _avatar_http_for_result(result) -> tuple[int, str]: + """Pick the HTTP status + detail for an unsuccessful ImageGenerationResult.""" + kind = getattr(result, "error_kind", None) or "unknown" + return _AVATAR_ERROR_HTTP.get(kind, _AVATAR_ERROR_HTTP["unknown"]) + # Diverse visual styles for default avatars — deterministically assigned from agent name hash # so each agent gets a unique look even when they share the same Docker type. _DEFAULT_AVATAR_STYLES = [ @@ -374,10 +415,8 @@ async def generate_avatar( service = get_image_generation_service() if not service.available: - raise HTTPException( - status_code=501, - detail="Image generation not available: GEMINI_API_KEY not configured", - ) + status, detail = _AVATAR_ERROR_HTTP["not_configured"] + raise HTTPException(status_code=status, detail=detail) result = await service.generate_image( prompt=identity_prompt, @@ -388,7 +427,8 @@ async def generate_avatar( ) if not result.success: - raise HTTPException(status_code=422, detail=result.error) + status, detail = _avatar_http_for_result(result) + raise HTTPException(status_code=status, detail=detail) # Save optimized display avatar (.webp) and full-quality reference (.png) AVATAR_DIR.mkdir(parents=True, exist_ok=True) @@ -454,10 +494,8 @@ async def regenerate_avatar( service = get_image_generation_service() if not service.available: - raise HTTPException( - status_code=501, - detail="Image generation not available: GEMINI_API_KEY not configured", - ) + status, detail = _AVATAR_ERROR_HTTP["not_configured"] + raise HTTPException(status_code=status, detail=detail) reference_bytes = ref_path.read_bytes() result = await service.generate_variation( @@ -468,7 +506,8 @@ async def regenerate_avatar( ) if not result.success: - raise HTTPException(status_code=422, detail=result.error) + status, detail = _avatar_http_for_result(result) + raise HTTPException(status_code=status, detail=detail) # Save as optimized display avatar only (reference stays the same) avatar_path = AVATAR_DIR / f"{agent_name}.webp" diff --git a/src/backend/services/image_generation_service.py b/src/backend/services/image_generation_service.py index 6f868ab9b..6c6d4298c 100644 --- a/src/backend/services/image_generation_service.py +++ b/src/backend/services/image_generation_service.py @@ -47,6 +47,35 @@ class ImageGenerationResult: use_case: str = "general" aspect_ratio: str = "1:1" error: Optional[str] = None + # #957: coarse classification so the router can pick a meaningful HTTP + # status and the frontend can render an actionable message instead of a + # raw upstream error. Kinds: + # not_configured — GEMINI_API_KEY missing + # invalid_input — use_case / aspect_ratio rejected before API call + # safety_filter — upstream returned no image (prompt blocked) + # rate_limited — upstream HTTP 429 + # upstream_error — upstream HTTP 5xx or unparseable response + # timeout — httpx timeout / connect error + # unknown — anything else + error_kind: Optional[str] = None + + +def _classify_exception(exc: BaseException) -> str: + """Map a generation-path exception to one of the error_kind values.""" + if isinstance(exc, httpx.TimeoutException): + return "timeout" + if isinstance(exc, (httpx.ConnectError, httpx.NetworkError)): + return "upstream_error" + msg = str(exc) + if "no image data" in msg or "safety filter" in msg.lower(): + return "safety_filter" + if "API error 429" in msg: + return "rate_limited" + if "API error 5" in msg: # 500/502/503/504 + return "upstream_error" + if "API error 4" in msg: # 400/401/403 — surfaced as upstream for now + return "upstream_error" + return "unknown" class ImageGenerationService: @@ -95,6 +124,7 @@ async def generate_image( use_case=use_case, aspect_ratio=aspect_ratio, error="GEMINI_API_KEY not configured", + error_kind="not_configured", ) if use_case not in VALID_USE_CASES: @@ -104,6 +134,7 @@ async def generate_image( use_case=use_case, aspect_ratio=aspect_ratio, error=f"Invalid use_case: {use_case}. Must be one of: {VALID_USE_CASES}", + error_kind="invalid_input", ) if aspect_ratio not in VALID_ASPECT_RATIOS: @@ -113,6 +144,7 @@ async def generate_image( use_case=use_case, aspect_ratio=aspect_ratio, error=f"Invalid aspect_ratio: {aspect_ratio}. Must be one of: {VALID_ASPECT_RATIOS}", + error_kind="invalid_input", ) log_prefix = f"[IMG {agent_name or 'platform'}]" @@ -145,7 +177,19 @@ async def generate_image( aspect_ratio=aspect_ratio, ) except Exception as e: - logger.error(f"{log_prefix} Image generation failed: {e}") + kind = _classify_exception(e) + logger.error( + "image_generation_failed", + extra={ + "agent_name": agent_name or "platform", + "use_case": use_case, + "aspect_ratio": aspect_ratio, + "error_kind": kind, + "exception_type": type(e).__name__, + "error_message": str(e)[:500], + "prompt_length": len(prompt), + }, + ) return ImageGenerationResult( success=False, refined_prompt=refined if refined != prompt else None, @@ -153,6 +197,7 @@ async def generate_image( use_case=use_case, aspect_ratio=aspect_ratio, error=str(e), + error_kind=kind, ) async def refine_prompt( @@ -336,6 +381,7 @@ async def generate_variation( original_prompt=prompt, aspect_ratio=aspect_ratio, error="GEMINI_API_KEY not configured", + error_kind="not_configured", ) log_prefix = f"[IMG {agent_name or 'platform'}]" @@ -370,13 +416,24 @@ async def generate_variation( aspect_ratio=aspect_ratio, ) except Exception as e: - logger.error(f"{log_prefix} Variation generation failed: {e}") + kind = _classify_exception(e) + logger.error( + "image_variation_failed", + extra={ + "agent_name": agent_name or "platform", + "aspect_ratio": aspect_ratio, + "error_kind": kind, + "exception_type": type(e).__name__, + "error_message": str(e)[:500], + }, + ) return ImageGenerationResult( success=False, original_prompt=prompt, use_case="avatar", aspect_ratio=aspect_ratio, error=str(e), + error_kind=kind, ) async def generate_emotion_variation( @@ -408,6 +465,7 @@ async def generate_emotion_variation( original_prompt=emotion_prompt, aspect_ratio=aspect_ratio, error="GEMINI_API_KEY not configured", + error_kind="not_configured", ) log_prefix = f"[IMG {agent_name or 'platform'}]" @@ -433,13 +491,24 @@ async def generate_emotion_variation( aspect_ratio=aspect_ratio, ) except Exception as e: - logger.error(f"{log_prefix} Emotion variation generation failed: {e}") + kind = _classify_exception(e) + logger.error( + "image_emotion_variation_failed", + extra={ + "agent_name": agent_name or "platform", + "aspect_ratio": aspect_ratio, + "error_kind": kind, + "exception_type": type(e).__name__, + "error_message": str(e)[:500], + }, + ) return ImageGenerationResult( success=False, original_prompt=emotion_prompt, use_case="avatar", aspect_ratio=aspect_ratio, error=str(e), + error_kind=kind, ) async def close(self): diff --git a/src/frontend/src/components/AvatarGenerateModal.vue b/src/frontend/src/components/AvatarGenerateModal.vue index 8c662f0cb..123d821fd 100644 --- a/src/frontend/src/components/AvatarGenerateModal.vue +++ b/src/frontend/src/components/AvatarGenerateModal.vue @@ -80,7 +80,7 @@