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
59 changes: 49 additions & 10 deletions src/backend/routers/avatar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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"
Expand Down
75 changes: 72 additions & 3 deletions src/backend/services/image_generation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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'}]"
Expand Down Expand Up @@ -145,14 +177,27 @@ 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,
original_prompt=prompt,
use_case=use_case,
aspect_ratio=aspect_ratio,
error=str(e),
error_kind=kind,
)

async def refine_prompt(
Expand Down Expand Up @@ -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'}]"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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'}]"
Expand All @@ -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):
Expand Down
30 changes: 24 additions & 6 deletions src/frontend/src/components/AvatarGenerateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@

<script setup>
import { ref, watch } from 'vue'
import axios from 'axios'
import api from '@/api'
import AgentAvatar from './AgentAvatar.vue'

const props = defineProps({
Expand All @@ -105,19 +105,37 @@ watch(() => props.show, (val) => {
}
})

// #957: Map axios failures to actionable strings. Backend now sends a
// friendly `detail` per kind, but we still need a fallback chain for the
// no-response cases (network, nginx 504 with HTML body) that strip detail.
function describeAvatarError(err, verb = 'generate') {
if (err?.response?.data?.detail && typeof err.response.data.detail === 'string') {
return err.response.data.detail
}
const status = err?.response?.status
if (status === 504) return `Avatar ${verb} timed out — please retry.`
if (status === 502 || status === 503) {
return `Image generation service is unavailable right now — please retry in a few minutes.`
}
if (!err?.response) {
return `Network error while trying to ${verb} avatar — check your connection and retry.`
}
return `Failed to ${verb} avatar (HTTP ${status}).`
}

async function generate() {
if (!identityPrompt.value.trim()) return
generating.value = true
error.value = ''

try {
await axios.post(`/api/agents/${props.agentName}/avatar/generate`, {
await api.post(`/api/agents/${props.agentName}/avatar/generate`, {
identity_prompt: identityPrompt.value.trim()
})
}, { timeout: 180000 })
emit('updated')
emit('close')
} catch (err) {
error.value = err.response?.data?.detail || 'Failed to generate avatar'
error.value = describeAvatarError(err, 'generate')
} finally {
generating.value = false
}
Expand All @@ -128,11 +146,11 @@ async function removeAvatar() {
error.value = ''

try {
await axios.delete(`/api/agents/${props.agentName}/avatar`)
await api.delete(`/api/agents/${props.agentName}/avatar`)
emit('updated')
emit('close')
} catch (err) {
error.value = err.response?.data?.detail || 'Failed to remove avatar'
error.value = describeAvatarError(err, 'remove')
} finally {
removing.value = false
}
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/test_image_generation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,64 @@ def test_default_values(self):
assert result.mime_type == "image/png"
assert result.use_case == "general"
assert result.aspect_ratio == "1:1"
assert result.error_kind is None # #957

def test_error_kind_field_is_settable(self):
"""#957: error_kind drives router status/detail mapping."""
mod = _load_service()
result = mod.ImageGenerationResult(
success=False,
error="GEMINI_API_KEY not configured",
error_kind="not_configured",
)
assert result.error_kind == "not_configured"


# =============================================================================
# #957 — _classify_exception
# =============================================================================

@pytest.mark.unit
class TestClassifyException:
"""Map upstream exceptions to coarse error_kind values for the router."""

def test_timeout_exception_classified_as_timeout(self):
import httpx
mod = _load_service()
kind = mod._classify_exception(httpx.ReadTimeout("read timed out"))
assert kind == "timeout"

def test_connect_error_classified_as_upstream(self):
import httpx
mod = _load_service()
kind = mod._classify_exception(httpx.ConnectError("nope"))
assert kind == "upstream_error"

def test_safety_filter_runtimeerror(self):
mod = _load_service()
kind = mod._classify_exception(
RuntimeError("Gemini image API returned no image data. ...")
)
assert kind == "safety_filter"

def test_rate_limit_runtimeerror(self):
mod = _load_service()
kind = mod._classify_exception(
RuntimeError("Gemini text API error 429: rate limit")
)
assert kind == "rate_limited"

def test_upstream_5xx(self):
mod = _load_service()
kind = mod._classify_exception(
RuntimeError("Gemini image API error 503: service unavailable")
)
assert kind == "upstream_error"

def test_unknown_default(self):
mod = _load_service()
kind = mod._classify_exception(ValueError("something else"))
assert kind == "unknown"


# =============================================================================
Expand Down
Loading