Turn provider content-policy errors into plain-language messages

litellm/provider errors (e.g. Gemini's BadRequestError for safety-
filtered generations) were surfacing as raw JSON-in-a-stack-trace,
unreadable to non-engineers using these nodes. Catch BadRequestError
around every litellm call in both nodes, parse the provider's nested
JSON payload, and re-raise as a plain sentence explaining that the
provider itself rejected the request for a content-policy reason (not
a bug) plus concrete guidance to rephrase the prompt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Bwg5aRmgChpwrVnpNgoQm
This commit is contained in:
Andy Ratsirarson
2026-09-16 15:52:39 -07:00
co-authored by Claude Sonnet 5
parent c24e649d1c
commit 64b7889d6b
+75 -16
View File
@@ -1,8 +1,10 @@
import asyncio
import json
import re
from io import BytesIO
from comfy_api.latest import ComfyAPI, InputImpl, io
from litellm import aimage_edit, aimage_generation, avideo_content, avideo_generation, avideo_status
from litellm import BadRequestError, aimage_edit, aimage_generation, avideo_content, avideo_generation, avideo_status
from litellm.interactions import acreate as acreate_interaction
from .models.azure import AzureModel
@@ -15,6 +17,51 @@ api = ComfyAPI()
_TERMINAL_STATUSES = ("completed", "failed")
_SAFETY_KEYWORDS = ("safety", "blocked", "policy", "harmful", "flagged", "violat")
def _extract_provider_error(exc_or_text) -> str:
"""Pull the human-readable message out of a provider error payload.
Providers often bury the actual explanation inside a JSON blob nested in
the exception text (or, for Gemini specifically, split across separate
"message" and "code" fields where "code" is actually a second sentence of
prose). Falls back to the raw string if there's nothing to parse out.
"""
text = str(exc_or_text)
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
try:
payload = json.loads(match.group(0))
except (ValueError, TypeError):
payload = None
if isinstance(payload, dict):
err = payload.get("error", payload)
if isinstance(err, dict):
parts = [p for p in (err.get("message"), err.get("code")) if isinstance(p, str)]
if parts:
return " ".join(parts)
return text
def _friendly_error_message(detail: str, provider_name: str, kind: str) -> str:
"""Rewrite a raw provider error into something a non-engineer can act on."""
label = provider_name.title() if provider_name else "The provider"
if any(keyword in detail.lower() for keyword in _SAFETY_KEYWORDS):
return (
f"{label} refused to generate this {kind} because it tripped their content "
f"safety filter — this is {label}'s policy, not a ComfyUI or workflow error. "
f"Their explanation: \"{detail}\" "
"Rephrase the prompt to tone down graphic, sexual, violent, or crude wording "
"(this can trigger even for comedic scenes, especially ones involving children, "
"injury, or profanity) and run it again."
)
return f"{label} rejected the {kind} request: {detail}"
def _raise_friendly_api_error(exc: Exception, provider_name: str, kind: str) -> None:
raise RuntimeError(_friendly_error_message(_extract_provider_error(exc), provider_name, kind)) from exc
_PROVIDERS = [GeminiModel, OpenAIModel, AzureModel, RunwayMLModel]
_PROVIDERS_BY_NAME = {p.PROVIDER: p for p in _PROVIDERS}
@@ -153,14 +200,20 @@ class APIVideoGenerate(io.ComfyNode):
await asyncio.wait([request], timeout=poll_interval)
elapsed = min(elapsed + poll_interval, timeout)
await api.execution.set_progress(elapsed, timeout, node_id=node_id)
response = await request
try:
response = await request
except BadRequestError as exc:
_raise_friendly_api_error(exc, provider_name, "video")
await api.execution.set_progress(timeout, timeout, node_id=node_id)
video_bytes = provider_cls.extract_video_bytes(response)
return io.NodeOutput(InputImpl.VideoFromFile(BytesIO(video_bytes)))
op = await avideo_generation(
model=model, prompt=prompt, timeout=timeout, **auth_kwargs, **extra_kwargs
)
try:
op = await avideo_generation(
model=model, prompt=prompt, timeout=timeout, **auth_kwargs, **extra_kwargs
)
except BadRequestError as exc:
_raise_friendly_api_error(exc, provider_name, "video")
elapsed = 0.0
await api.execution.set_progress(0.0, timeout, node_id=node_id)
@@ -170,10 +223,13 @@ class APIVideoGenerate(io.ComfyNode):
await asyncio.sleep(poll_interval)
elapsed += poll_interval
await api.execution.set_progress(min(elapsed, timeout), timeout, node_id=node_id)
op = await avideo_status(video_id=op.id, timeout=timeout, **auth_kwargs)
try:
op = await avideo_status(video_id=op.id, timeout=timeout, **auth_kwargs)
except BadRequestError as exc:
_raise_friendly_api_error(exc, provider_name, "video")
if op.status == "failed":
raise RuntimeError(f"Video generation failed: {op.error}")
raise RuntimeError(_friendly_error_message(_extract_provider_error(op.error), provider_name, "video"))
video_bytes = await avideo_content(video_id=op.id, **auth_kwargs)
await api.execution.set_progress(timeout, timeout, node_id=node_id)
@@ -264,15 +320,18 @@ class APIImageGenerate(io.ComfyNode):
provider_cls = _IMAGE_PROVIDERS_BY_NAME[provider_name]
model = f"{provider_name}/{model_id}"
if source_mode == "text":
extra_kwargs = provider_cls.image_kwargs(model_id, settings)
response = await aimage_generation(model=model, prompt=prompt, **auth_kwargs, **extra_kwargs)
else:
extra_kwargs = provider_cls.image_to_image_kwargs(model_id, settings)
image = extra_kwargs.pop("image")
response = await aimage_edit(
model=model, prompt=prompt, image=image, **auth_kwargs, **extra_kwargs
)
try:
if source_mode == "text":
extra_kwargs = provider_cls.image_kwargs(model_id, settings)
response = await aimage_generation(model=model, prompt=prompt, **auth_kwargs, **extra_kwargs)
else:
extra_kwargs = provider_cls.image_to_image_kwargs(model_id, settings)
image = extra_kwargs.pop("image")
response = await aimage_edit(
model=model, prompt=prompt, image=image, **auth_kwargs, **extra_kwargs
)
except BadRequestError as exc:
_raise_friendly_api_error(exc, provider_name, "image")
image_bytes_list = Model.image_bytes_list_from_response(response)
tensor = Model.image_bytes_list_to_tensor(image_bytes_list)