Files
IxMxAMAR bb6efd22a6 v2.1.0: audit + 11 new utility nodes + 101 tests
Big audit + fix release driven by two full-codebase Gemini Pro reviews
and first-party verification against the live ElevenLabs API spec.

Security
- Scrub API keys from every raised error (URL params, JSON detail, conn
  errors). Defense against leaking into ComfyUI logs / workflow PNGs.
- AccountInfo redacts PII (email, name, billing IDs) by default and now
  uses /v1/user/subscription (no PII).
- Download size cap (500MB default) — was unbounded BytesIO.
- Multipart file pointers reset between retries (was sending empty body
  on attempt 2).
- SaveAudio sanitizes filename + subdir against ../ traversal.

Fixed — Credit-burning bugs
- IS_CHANGED=NaN removed from paid nodes; replaced with InputCacheMixin
  that hashes (inputs minus api_key) so identical re-queues don't re-bill.
- VoiceClone / VoiceCreate require explicit create=True (was uploading
  on every queue, burning voice slots).

Fixed — API contract bugs (silent failures vs current spec)
- Music: uses music_length_ms (was duration_seconds), 600s max (was
  300), exposes composition_plan, respect_sections_durations,
  store_for_inpainting, sign_with_c2pa, model_id=music_v1.
- SFX: model_id=eleven_text_to_sound_v2 (was eleven_sfx_v2 — invalid).
- TTS: forces apply_text_normalization=off for eleven_v3 (only valid
  value per spec); raises early if turbo/flash_v2_5 lacks language_code.
- STT: actually sends timestamps_granularity=none when chosen.
- TTS: malformed pronunciation_dictionary_locators raises instead of
  silently dropping.

Fixed — Audio I/O
- PCM decode pads odd byte count so truncated streams don't ValueError.
- audio_to_comfy returns 1s silence on empty body (was crashing).
- Fallback silence is 1s @ 44.1kHz, not [1,1,1] (downstream STFT was
  asserting window_size).
- comfy_audio_to_bytes accepts 1-D / 2-D / 3-D waveforms.
- NamedTemporaryFile replaced with BytesIO (Windows handle leak + reopen).
- parse_error_response sniffs Content-Type, returns <binary response>
  for non-text bodies (was UnicodeDecodeError on binary 500s).

Fixed — Retry hardening
- Retry-After honors HTTP-date format (RFC 7231).
- Exponential backoff clamped to 60s + ±25% jitter.
- Voice cache bounded LRU (32 entries) — was unbounded growth.
- Pagination hard-cap 100 pages (belt-and-braces).

Added — 11 new utility nodes
GetVoiceByName, VoiceTagInserter, SubtitleExport (SRT/VTT), AudioConcat
(resample + broadcast), AudioNormalize (peak/RMS dBFS), AudioMetadata,
AudioChannels (mono/stereo/strip), AudioTrim, CostEstimator,
VoiceSettingsPreset, SaveAudio (sanitized).

Added — Quality-of-life
- enable_logging toggle (zero-retention mode) on TTS/TTSTimestamps/
  STS/Dialogue.
- FetchVoices: gender filter + count output.
- VoiceDesign: returns all 3 previews (was throwing away the 2 you
  paid for); preview_index selector.
- Dialogue: validates total text length, explicit model dropdown,
  defaults normalization=off (required for v3).
- STT: adds scribe_v1.
- SFX: live duration validation (0.5-30s).
- previous_text/next_text tooltips clarified (context, not output).

Tests: 101/101 passing (was 0). All HTTP mocked — no real API calls.
- test_audio_conversions.py (13) — AUDIO dict shape, PCM round-trip
- test_errors_scrubbing.py (15) — secret-leakage regression, parser
- test_retry.py (18) — backoff, file-pointer reset, size cap
- test_pagination.py (7) — termination + LRU bound
- test_nodes.py (48) — node validation, credit safety, utility nodes

Node count: 15 -> 26. Audit notes in CHANGELOG.md.
2026-05-17 18:16:30 +05:30

133 lines
4.5 KiB
Python

"""Standardized API error classes and response parsing for all services."""
import json
import re
class APIError(RuntimeError):
"""Base API error with service name, HTTP status code, and detail message."""
def __init__(self, service: str, status_code: int, detail: str):
self.service = service
self.status_code = status_code
self.detail = detail
super().__init__(f"[{service}] HTTP {status_code}: {detail}")
class APITransientError(APIError):
"""Retryable API error (e.g. 429, 500, 502, 503, 504)."""
pass
class APIPermanentError(APIError):
"""Non-retryable API error (e.g. 400, 401, 403, 404)."""
pass
class APIQuotaError(APIPermanentError):
"""Credits or quota exhausted."""
pass
_MAX_DETAIL_LEN = 500
# Pattern for ElevenLabs API keys: typically `xi_` or hex of length ~40-48,
# but we also redact anything that looks like a high-entropy alnum token >=20 chars
# in case a key flows through. Conservative: only redact tokens that look key-ish.
_KEY_PAT = re.compile(r"(?i)((?:xi[_-])?(?:api[_-])?key[=:\s\"\']\s*)([A-Za-z0-9_\-\.]{20,})")
_URL_PARAM_PAT = re.compile(r"(?i)(\?|&)(xi[-_]api[-_]key|api[-_]key|key|token)=([^&\s\"\']+)")
def _safe_body(resp_or_body) -> str:
"""Extract a safe textual body from a response or raw body.
Returns a short placeholder for binary / huge / undecodable bodies so we
never echo an entire 5MB binary stream into an exception message.
"""
# Accept either a string or a requests.Response
if isinstance(resp_or_body, str):
body = resp_or_body
else:
try:
ct = resp_or_body.headers.get("Content-Type", "") if hasattr(resp_or_body, "headers") else ""
ct_l = ct.lower()
if any(t in ct_l for t in ("audio/", "video/", "image/", "octet-stream")):
return "<binary response>"
body = resp_or_body.text
except Exception:
return "<unreadable response>"
if body is None:
return ""
if len(body) > 4096:
body = body[:4096] + "...<truncated>"
return body
def scrub_secrets(text: str, extra_secrets=None) -> str:
"""Best-effort scrub of API keys / tokens from a string.
1. Replaces any explicit `extra_secrets` strings (e.g. the user's key
passed by caller) wholesale.
2. Redacts `?xi-api-key=...`, `&api_key=...` query-string params.
3. Redacts key-like patterns near `key:` / `api_key=` markers.
NOT cryptographically rigorous — defense in depth, not a perfect filter.
"""
if not text:
return text
out = text
if extra_secrets:
for s in extra_secrets:
if s and isinstance(s, str) and len(s) >= 8:
out = out.replace(s, "***REDACTED***")
out = _URL_PARAM_PAT.sub(lambda m: f"{m.group(1)}{m.group(2)}=***REDACTED***", out)
out = _KEY_PAT.sub(lambda m: f"{m.group(1)}***REDACTED***", out)
return out
def parse_error_response(service: str, status_code: int, body, extra_secrets=None) -> APIError:
"""Parse an error response body and return the appropriate APIError subclass.
`body` may be a string OR a `requests.Response` — Content-Type sniffing
avoids decoding binary payloads as text. `extra_secrets` (e.g. the API
key) is scrubbed from the detail message before construction so user
secrets cannot leak into ComfyUI logs / UI / saved workflow PNGs.
"""
body_str = _safe_body(body)
detail = ""
try:
data = json.loads(body_str)
if isinstance(data, dict) and "detail" in data:
d = data["detail"]
if isinstance(d, dict) and "message" in d:
detail = d["message"]
elif isinstance(d, str):
detail = d
else:
detail = str(d)
elif isinstance(data, dict) and "message" in data:
detail = data["message"]
else:
detail = str(data)
except (json.JSONDecodeError, ValueError, TypeError):
detail = body_str
if not isinstance(detail, str):
detail = str(detail)
detail = scrub_secrets(detail, extra_secrets=extra_secrets)
if len(detail) > _MAX_DETAIL_LEN:
detail = detail[:_MAX_DETAIL_LEN] + "..."
if status_code == 429 or status_code >= 500:
return APITransientError(service, status_code, detail)
elif status_code in (402, 403) and any(
kw in detail.lower() for kw in ("quota", "credit", "limit", "exhausted")
):
return APIQuotaError(service, status_code, detail)
else:
return APIPermanentError(service, status_code, detail)