337 lines
13 KiB
Python
337 lines
13 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 _raise_openai_http_error(response, payload):
|
|
"""Raise the same stable error classes for JSON and streaming requests."""
|
|
if 200 <= response.status_code < 300:
|
|
return
|
|
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}")
|
|
|
|
|
|
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
|
|
)
|
|
_raise_openai_http_error(response, payload)
|
|
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 _iter_sse_data(response):
|
|
"""Yield complete SSE data payloads, independent of HTTP chunk boundaries."""
|
|
data_lines = []
|
|
# The SSE specification mandates UTF-8. Requests may otherwise infer
|
|
# ISO-8859-1 for text/event-stream responses without a charset.
|
|
response.encoding = "utf-8"
|
|
# requests defaults to 512-byte buffering here. Small token events can
|
|
# otherwise sit in that buffer until generation is nearly complete,
|
|
# making a real SSE response appear non-streaming in the UI.
|
|
for line in response.iter_lines(chunk_size=1, decode_unicode=True):
|
|
if line is None:
|
|
continue
|
|
if isinstance(line, bytes):
|
|
line = line.decode(response.encoding or "utf-8", errors="replace")
|
|
line = line.rstrip("\r")
|
|
if line == "":
|
|
if data_lines:
|
|
yield "\n".join(data_lines)
|
|
data_lines.clear()
|
|
continue
|
|
if line.startswith(":"):
|
|
continue
|
|
if line.startswith("data:"):
|
|
data_lines.append(line[5:].lstrip())
|
|
if data_lines:
|
|
yield "\n".join(data_lines)
|
|
|
|
|
|
def _completion_stream_event(event):
|
|
"""Return the provider-neutral meaning of a Chat Completions event."""
|
|
choices = event.get("choices")
|
|
if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
|
|
return None, None, False
|
|
choice = choices[0]
|
|
delta = choice.get("delta")
|
|
delta = delta if isinstance(delta, dict) else {}
|
|
content = delta.get("content")
|
|
reasoning = delta.get("reasoning_content")
|
|
return (
|
|
content if isinstance(content, str) and content else None,
|
|
"reasoning" if isinstance(reasoning, str) and reasoning else None,
|
|
choice.get("finish_reason") is not None,
|
|
)
|
|
|
|
|
|
def post_openai_sse_events(
|
|
endpoint, headers, payload, timeout, proxies, on_event,
|
|
):
|
|
"""POST an SSE request and deliver decoded JSON events in wire order.
|
|
|
|
Protocol-specific aggregation belongs to the caller. The boolean return
|
|
value reports whether the OpenAI-compatible ``[DONE]`` sentinel was seen.
|
|
"""
|
|
stream_headers = {
|
|
**headers,
|
|
"Accept": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
"Accept-Encoding": "identity",
|
|
}
|
|
saw_done = False
|
|
with requests.post(
|
|
endpoint,
|
|
headers=stream_headers,
|
|
json=payload,
|
|
timeout=timeout,
|
|
proxies=proxies,
|
|
stream=True,
|
|
) as response:
|
|
_raise_openai_http_error(response, payload)
|
|
content_type = response.headers.get("Content-Type", "").lower()
|
|
if "text/event-stream" not in content_type:
|
|
raise ValueError("Streaming API returned a non-SSE response")
|
|
|
|
for raw_data in _iter_sse_data(response):
|
|
if raw_data.strip() == "[DONE]":
|
|
saw_done = True
|
|
break
|
|
try:
|
|
event = json.loads(raw_data)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("Streaming API returned invalid SSE JSON") from exc
|
|
if not isinstance(event, dict):
|
|
raise ValueError("Streaming API returned a non-object SSE event")
|
|
if event.get("error"):
|
|
raise ValueError(f"Streaming API failed: {event['error']}")
|
|
on_event(event)
|
|
return saw_done
|
|
|
|
|
|
def post_openai_stream(
|
|
endpoint,
|
|
headers,
|
|
payload,
|
|
timeout,
|
|
proxies,
|
|
protocol,
|
|
on_delta,
|
|
final_response_parser=None,
|
|
on_activity=None,
|
|
):
|
|
"""POST an OpenAI-compatible SSE request and return its complete text."""
|
|
if protocol not in ("openai-completions", "openai-responses"):
|
|
raise ValueError(f"Unsupported streaming protocol: {protocol}")
|
|
|
|
chunks = []
|
|
completed_response = None
|
|
reasoning_notified = False
|
|
completed = False
|
|
stream_headers = {
|
|
**headers,
|
|
"Accept": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
# Some OpenAI-compatible gateways gzip SSE responses and only flush
|
|
# compressed blocks occasionally. Identity encoding keeps token-sized
|
|
# events observable as soon as the provider sends them.
|
|
"Accept-Encoding": "identity",
|
|
}
|
|
with requests.post(
|
|
endpoint,
|
|
headers=stream_headers,
|
|
json=payload,
|
|
timeout=timeout,
|
|
proxies=proxies,
|
|
stream=True,
|
|
) as response:
|
|
_raise_openai_http_error(response, payload)
|
|
content_type = response.headers.get("Content-Type", "").lower()
|
|
if "text/event-stream" not in content_type:
|
|
raise ValueError("Streaming API returned a non-SSE response")
|
|
|
|
for raw_data in _iter_sse_data(response):
|
|
if raw_data.strip() == "[DONE]":
|
|
completed = True
|
|
break
|
|
try:
|
|
event = json.loads(raw_data)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("Streaming API returned invalid SSE JSON") from exc
|
|
if not isinstance(event, dict):
|
|
raise ValueError("Streaming API returned a non-object SSE event")
|
|
if event.get("error"):
|
|
raise ValueError(f"Streaming API failed: {event['error']}")
|
|
|
|
if protocol == "openai-completions":
|
|
text, activity, event_completed = _completion_stream_event(event)
|
|
completed = completed or event_completed
|
|
else:
|
|
event_type = event.get("type")
|
|
text = (
|
|
event.get("delta")
|
|
if event_type == "response.output_text.delta"
|
|
else None
|
|
)
|
|
activity = (
|
|
"reasoning"
|
|
if event_type == "response.reasoning_text.delta"
|
|
and isinstance(event.get("delta"), str)
|
|
and event.get("delta")
|
|
else None
|
|
)
|
|
if event_type == "response.completed":
|
|
completed_response = event.get("response")
|
|
completed = True
|
|
if event_type in ("error", "response.failed", "response.incomplete"):
|
|
detail = event.get("error") or event.get("response") or event
|
|
raise ValueError(f"Streaming API failed: {detail}")
|
|
|
|
if activity == "reasoning" and not reasoning_notified:
|
|
reasoning_notified = True
|
|
if on_activity is not None:
|
|
on_activity("reasoning")
|
|
|
|
if isinstance(text, str) and text:
|
|
chunks.append(text)
|
|
on_delta(text)
|
|
|
|
if not completed:
|
|
raise ValueError("Streaming API ended before a completion marker")
|
|
|
|
result = "".join(chunks)
|
|
if (
|
|
protocol == "openai-responses"
|
|
and isinstance(completed_response, dict)
|
|
and final_response_parser is not None
|
|
):
|
|
try:
|
|
final_result = final_response_parser(completed_response)
|
|
except (TypeError, ValueError):
|
|
if not result:
|
|
raise
|
|
else:
|
|
if isinstance(final_result, str) and final_result.strip():
|
|
result = final_result
|
|
if not isinstance(result, str) or not result.strip():
|
|
if reasoning_notified:
|
|
raise ValueError("Streaming API returned no formal text content")
|
|
raise ValueError("Streaming API returned no text content")
|
|
return result
|
|
|
|
|
|
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')}"
|