From 64b7889d6b3e42376c0ca58b3d9c2d15616b71ba Mon Sep 17 00:00:00 2001 From: Andy Ratsirarson Date: Wed, 16 Sep 2026 15:52:39 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_014Bwg5aRmgChpwrVnpNgoQm --- src/nodes.py | 91 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/src/nodes.py b/src/nodes.py index 0cd68b3..9662ae2 100644 --- a/src/nodes.py +++ b/src/nodes.py @@ -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)