131 lines
5.2 KiB
Python
131 lines
5.2 KiB
Python
"""Shared request and media helpers for OpenAI-compatible nodes."""
|
|
|
|
import base64
|
|
import json
|
|
from io import BytesIO
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
import requests
|
|
|
|
from .config_utils import get_config_section
|
|
|
|
|
|
class ToolChoiceRejected(RuntimeError):
|
|
"""The provider explicitly rejected the request's tool_choice field."""
|
|
|
|
|
|
class FunctionToolsRejected(RuntimeError):
|
|
"""The provider explicitly rejected the requested function tool schema."""
|
|
|
|
|
|
def post_openai_json(endpoint, headers, payload, timeout, proxies):
|
|
"""POST an OpenAI-compatible JSON request with stable error classification."""
|
|
response = requests.post(
|
|
endpoint, headers=headers, json=payload, timeout=timeout, proxies=proxies
|
|
)
|
|
if response.status_code < 200 or response.status_code >= 300:
|
|
detail = response.text[:1000]
|
|
lower_detail = detail.lower()
|
|
choice_rejected = (
|
|
response.status_code in (400, 404, 415, 422)
|
|
and "tool_choice" in payload
|
|
and any(marker in lower_detail for marker in ("tool_choice", "tool choice"))
|
|
and any(
|
|
marker in lower_detail
|
|
for marker in (
|
|
"unsupported", "unknown", "unrecognized", "invalid",
|
|
"not support", "not allowed", "not permitted", "extra field",
|
|
)
|
|
)
|
|
)
|
|
if choice_rejected:
|
|
raise ToolChoiceRejected(
|
|
f"API request rejected tool_choice ({response.status_code}): {detail}"
|
|
)
|
|
tool_error = response.status_code in (400, 404, 415, 422) and any(
|
|
marker in lower_detail
|
|
for marker in ("tool", "function", "unsupported", "unknown field")
|
|
)
|
|
if payload.get("tools") and tool_error:
|
|
raise FunctionToolsRejected(
|
|
f"API request failed ({response.status_code}); this API/model may not support "
|
|
f"the requested Skill tools: {detail}"
|
|
)
|
|
raise RuntimeError(f"API request failed ({response.status_code}): {detail}")
|
|
if not response.text.strip():
|
|
raise ValueError("API returned an empty response")
|
|
try:
|
|
return response.json()
|
|
except ValueError as exc:
|
|
raise ValueError("API returned invalid JSON") from exc
|
|
|
|
|
|
def parse_json_options(options_json):
|
|
if not options_json or not str(options_json).strip():
|
|
return {}
|
|
try:
|
|
value = json.loads(options_json)
|
|
except (TypeError, json.JSONDecodeError) as exc:
|
|
raise ValueError(f"Advanced options must be valid JSON: {exc}")
|
|
if not isinstance(value, dict):
|
|
raise ValueError("Advanced options JSON must be a top-level object")
|
|
return value
|
|
|
|
|
|
def resolve_endpoint(base_url, api_protocol):
|
|
if not isinstance(base_url, str) or not base_url.strip():
|
|
raise ValueError("base_url cannot be empty")
|
|
if api_protocol not in ("openai-completions", "openai-responses"):
|
|
raise ValueError(f"Unsupported API protocol: {api_protocol}")
|
|
raw = base_url.strip()
|
|
parts = urlsplit(raw)
|
|
path = parts.path.rstrip("/")
|
|
lower_path = path.lower()
|
|
if lower_path.endswith("/chat/completions"):
|
|
if api_protocol != "openai-completions":
|
|
raise ValueError("base_url points to /chat/completions but protocol is openai-responses")
|
|
endpoint_path = path
|
|
elif lower_path.endswith("/responses"):
|
|
if api_protocol != "openai-responses":
|
|
raise ValueError("base_url points to /responses but protocol is openai-completions")
|
|
endpoint_path = path
|
|
else:
|
|
suffix = "/chat/completions" if api_protocol == "openai-completions" else "/responses"
|
|
endpoint_path = f"{path}{suffix}" if path else suffix
|
|
return urlunsplit((parts.scheme, parts.netloc, endpoint_path, parts.query, parts.fragment))
|
|
|
|
|
|
def get_proxy_config(proxy_options=None):
|
|
config = proxy_options if proxy_options is not None else get_config_section("proxy")
|
|
if not isinstance(config, dict) or not config.get("enable", False):
|
|
return None
|
|
proxies = {}
|
|
for key in ("http", "https"):
|
|
value = config.get(key, "")
|
|
if isinstance(value, str) and value.strip():
|
|
proxies[key] = value.strip()
|
|
return proxies or None
|
|
|
|
|
|
def video_to_data_uri(video, max_bytes=64 * 1024 * 1024):
|
|
"""Read a ComfyUI VideoInput into a bounded base64 data URI."""
|
|
if video is None or not hasattr(video, "get_stream_source"):
|
|
raise ValueError("Invalid video input")
|
|
source = video.get_stream_source()
|
|
if isinstance(source, BytesIO):
|
|
source.seek(0)
|
|
data = source.read(max_bytes + 1)
|
|
else:
|
|
with open(source, "rb") as handle:
|
|
data = handle.read(max_bytes + 1)
|
|
if len(data) > max_bytes:
|
|
raise ValueError(f"Video exceeds the maximum size of {max_bytes} bytes")
|
|
if isinstance(source, BytesIO):
|
|
source.seek(0)
|
|
mime = getattr(video, "container", None)
|
|
mime = str(mime).lower() if mime else ""
|
|
mime = {"mp4": "video/mp4", "webm": "video/webm", "mkv": "video/x-matroska"}.get(mime, mime)
|
|
if not mime.startswith("video/"):
|
|
mime = "video/mp4"
|
|
return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
|