Skip to content

Commit 0738e4e

Browse files
robinjhuangchristian-byrneyoland68
authored
[API nodes] Add backbone for supporting api nodes in ComfyUI (comfyanonymous#7745)
* Add Ideogram generate node. * Add staging api. * COMFY_API_NODE_NAME node property * switch to boolean flag and use original node name for id * add optional to type * Add API_NODE and common error for missing auth token (#5) * Add Minimax Video Generation + Async Task queue polling example (#6) * [Minimax] Show video preview and embed workflow in ouput (#7) * [API Nodes] Send empty request body instead of empty dictionary. (#8) * Fixed: removed function from rebase. * Add pydantic. * Remove uv.lock * Remove polling operations. * Update stubs workflow. * Remove polling comments. * Update stubs. * Use pydantic v2. * Use pydantic v2. * Add basic OpenAITextToImage node * Add. * convert image to tensor. * Improve types. * Ruff. * Push tests. * Handle multi-form data. - Don't set content-type for multi-part/form - Use data field instead of JSON * Change to api.comfy.org * Handle error code 409. * Remove nodes. --------- Co-authored-by: bymyself <cbyrne@comfy.org> Co-authored-by: Yoland Y <4950057+yoland68@users.noreply.github.com>
1 parent 92cdc69 commit 0738e4e

File tree

5 files changed

+344
-1
lines changed

5 files changed

+344
-1
lines changed

comfy/comfy_types/node_typing.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Comfy-specific type hinting"""
22

33
from __future__ import annotations
4-
from typing import Literal, TypedDict
4+
from typing import Literal, TypedDict, Optional
55
from typing_extensions import NotRequired
66
from abc import ABC, abstractmethod
77
from enum import Enum
@@ -229,6 +229,8 @@ class ComfyNodeABC(ABC):
229229
"""Flags a node as experimental, informing users that it may change or not work as expected."""
230230
DEPRECATED: bool
231231
"""Flags a node as deprecated, indicating to users that they should find alternatives to this node."""
232+
API_NODE: Optional[bool]
233+
"""Flags a node as an API node."""
232234

233235
@classmethod
234236
@abstractmethod

comfy_api_nodes/__init__.py

Whitespace-only changes.

comfy_api_nodes/apis/client.py

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import logging
2+
3+
"""
4+
API Client Framework for api.comfy.org.
5+
6+
This module provides a flexible framework for making API requests from ComfyUI nodes.
7+
It supports both synchronous and asynchronous API operations with proper type validation.
8+
9+
Key Components:
10+
--------------
11+
1. ApiClient - Handles HTTP requests with authentication and error handling
12+
2. ApiEndpoint - Defines a single HTTP endpoint with its request/response models
13+
3. ApiOperation - Executes a single synchronous API operation
14+
15+
Usage Examples:
16+
--------------
17+
18+
# Example 1: Synchronous API Operation
19+
# ------------------------------------
20+
# For a simple API call that returns the result immediately:
21+
22+
# 1. Create the API client
23+
api_client = ApiClient(
24+
base_url="https://api.example.com",
25+
api_key="your_api_key_here",
26+
timeout=30.0,
27+
verify_ssl=True
28+
)
29+
30+
# 2. Define the endpoint
31+
user_info_endpoint = ApiEndpoint(
32+
path="/v1/users/me",
33+
method=HttpMethod.GET,
34+
request_model=EmptyRequest, # No request body needed
35+
response_model=UserProfile, # Pydantic model for the response
36+
query_params=None
37+
)
38+
39+
# 3. Create the request object
40+
request = EmptyRequest()
41+
42+
# 4. Create and execute the operation
43+
operation = ApiOperation(
44+
endpoint=user_info_endpoint,
45+
request=request
46+
)
47+
user_profile = operation.execute(client=api_client) # Returns immediately with the result
48+
49+
"""
50+
51+
from typing import (
52+
Dict,
53+
Type,
54+
Optional,
55+
Any,
56+
TypeVar,
57+
Generic,
58+
)
59+
from pydantic import BaseModel
60+
from enum import Enum
61+
import json
62+
import requests
63+
from urllib.parse import urljoin
64+
65+
T = TypeVar("T", bound=BaseModel)
66+
R = TypeVar("R", bound=BaseModel)
67+
68+
class EmptyRequest(BaseModel):
69+
"""Base class for empty request bodies.
70+
For GET requests, fields will be sent as query parameters."""
71+
72+
pass
73+
74+
75+
class HttpMethod(str, Enum):
76+
GET = "GET"
77+
POST = "POST"
78+
PUT = "PUT"
79+
DELETE = "DELETE"
80+
PATCH = "PATCH"
81+
82+
83+
class ApiClient:
84+
"""
85+
Client for making HTTP requests to an API with authentication and error handling.
86+
"""
87+
88+
def __init__(
89+
self,
90+
base_url: str,
91+
api_key: Optional[str] = None,
92+
timeout: float = 30.0,
93+
verify_ssl: bool = True,
94+
):
95+
self.base_url = base_url
96+
self.api_key = api_key
97+
self.timeout = timeout
98+
self.verify_ssl = verify_ssl
99+
100+
def get_headers(self) -> Dict[str, str]:
101+
"""Get headers for API requests, including authentication if available"""
102+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
103+
104+
if self.api_key:
105+
headers["Authorization"] = f"Bearer {self.api_key}"
106+
107+
return headers
108+
109+
def request(
110+
self,
111+
method: str,
112+
path: str,
113+
params: Optional[Dict[str, Any]] = None,
114+
json: Optional[Dict[str, Any]] = None,
115+
files: Optional[Dict[str, Any]] = None,
116+
headers: Optional[Dict[str, str]] = None,
117+
) -> Dict[str, Any]:
118+
"""
119+
Make an HTTP request to the API
120+
121+
Args:
122+
method: HTTP method (GET, POST, etc.)
123+
path: API endpoint path (will be joined with base_url)
124+
params: Query parameters
125+
json: JSON body data
126+
files: Files to upload
127+
headers: Additional headers
128+
129+
Returns:
130+
Parsed JSON response
131+
132+
Raises:
133+
requests.RequestException: If the request fails
134+
"""
135+
url = urljoin(self.base_url, path)
136+
self.check_auth_token(self.api_key)
137+
# Combine default headers with any provided headers
138+
request_headers = self.get_headers()
139+
if headers:
140+
request_headers.update(headers)
141+
142+
# Let requests handle the content type when files are present.
143+
if files:
144+
del request_headers["Content-Type"]
145+
146+
logging.debug(f"[DEBUG] Request Headers: {request_headers}")
147+
logging.debug(f"[DEBUG] Files: {files}")
148+
logging.debug(f"[DEBUG] Params: {params}")
149+
logging.debug(f"[DEBUG] Json: {json}")
150+
151+
try:
152+
# If files are present, use data parameter instead of json
153+
if files:
154+
form_data = {}
155+
if json:
156+
form_data.update(json)
157+
response = requests.request(
158+
method=method,
159+
url=url,
160+
params=params,
161+
data=form_data, # Use data instead of json
162+
files=files,
163+
headers=request_headers,
164+
timeout=self.timeout,
165+
verify=self.verify_ssl,
166+
)
167+
else:
168+
response = requests.request(
169+
method=method,
170+
url=url,
171+
params=params,
172+
json=json,
173+
headers=request_headers,
174+
timeout=self.timeout,
175+
verify=self.verify_ssl,
176+
)
177+
178+
# Raise exception for error status codes
179+
response.raise_for_status()
180+
except requests.ConnectionError:
181+
raise Exception(
182+
f"Unable to connect to the API server at {self.base_url}. Please check your internet connection or verify the service is available."
183+
)
184+
185+
except requests.Timeout:
186+
raise Exception(
187+
f"Request timed out after {self.timeout} seconds. The server might be experiencing high load or the operation is taking longer than expected."
188+
)
189+
190+
except requests.HTTPError as e:
191+
status_code = e.response.status_code if hasattr(e, "response") else None
192+
error_message = f"HTTP Error: {str(e)}"
193+
194+
# Try to extract detailed error message from JSON response
195+
try:
196+
if hasattr(e, "response") and e.response.content:
197+
error_json = e.response.json()
198+
if "error" in error_json and "message" in error_json["error"]:
199+
error_message = f"API Error: {error_json['error']['message']}"
200+
if "type" in error_json["error"]:
201+
error_message += f" (Type: {error_json['error']['type']})"
202+
else:
203+
error_message = f"API Error: {error_json}"
204+
except Exception as json_error:
205+
# If we can't parse the JSON, fall back to the original error message
206+
logging.debug(f"[DEBUG] Failed to parse error response: {str(json_error)}")
207+
208+
logging.debug(f"[DEBUG] API Error: {error_message} (Status: {status_code})")
209+
if hasattr(e, "response") and e.response.content:
210+
logging.debug(f"[DEBUG] Response content: {e.response.content}")
211+
if status_code == 401:
212+
error_message = "Unauthorized: Please login first to use this node."
213+
if status_code == 402:
214+
error_message = "Payment Required: Please add credits to your account to use this node."
215+
if status_code == 409:
216+
error_message = "There is a problem with your account. Please contact support@comfy.org. "
217+
if status_code == 429:
218+
error_message = "Rate Limit Exceeded: Please try again later."
219+
raise Exception(error_message)
220+
221+
# Parse and return JSON response
222+
if response.content:
223+
return response.json()
224+
return {}
225+
226+
def check_auth_token(self, auth_token):
227+
"""Verify that an auth token is present."""
228+
if auth_token is None:
229+
raise Exception("Please login first to use this node.")
230+
return auth_token
231+
232+
233+
class ApiEndpoint(Generic[T, R]):
234+
"""Defines an API endpoint with its request and response types"""
235+
236+
def __init__(
237+
self,
238+
path: str,
239+
method: HttpMethod,
240+
request_model: Type[T],
241+
response_model: Type[R],
242+
query_params: Optional[Dict[str, Any]] = None,
243+
):
244+
"""Initialize an API endpoint definition.
245+
246+
Args:
247+
path: The URL path for this endpoint, can include placeholders like {id}
248+
method: The HTTP method to use (GET, POST, etc.)
249+
request_model: Pydantic model class that defines the structure and validation rules for API requests to this endpoint
250+
response_model: Pydantic model class that defines the structure and validation rules for API responses from this endpoint
251+
query_params: Optional dictionary of query parameters to include in the request
252+
"""
253+
self.path = path
254+
self.method = method
255+
self.request_model = request_model
256+
self.response_model = response_model
257+
self.query_params = query_params or {}
258+
259+
260+
class SynchronousOperation(Generic[T, R]):
261+
"""
262+
Represents a single synchronous API operation.
263+
"""
264+
265+
def __init__(
266+
self,
267+
endpoint: ApiEndpoint[T, R],
268+
request: T,
269+
files: Optional[Dict[str, Any]] = None,
270+
api_base: str = "https://api.comfy.org",
271+
auth_token: Optional[str] = None,
272+
timeout: float = 60.0,
273+
verify_ssl: bool = True,
274+
):
275+
self.endpoint = endpoint
276+
self.request = request
277+
self.response = None
278+
self.error = None
279+
self.api_base = api_base
280+
self.auth_token = auth_token
281+
self.timeout = timeout
282+
self.verify_ssl = verify_ssl
283+
self.files = files
284+
def execute(self, client: Optional[ApiClient] = None) -> R:
285+
"""Execute the API operation using the provided client or create one"""
286+
try:
287+
# Create client if not provided
288+
if client is None:
289+
if self.api_base is None:
290+
raise ValueError("Either client or api_base must be provided")
291+
client = ApiClient(
292+
base_url=self.api_base,
293+
api_key=self.auth_token,
294+
timeout=self.timeout,
295+
verify_ssl=self.verify_ssl,
296+
)
297+
298+
# Convert request model to dict, but use None for EmptyRequest
299+
request_dict = None if isinstance(self.request, EmptyRequest) else self.request.model_dump(exclude_none=True)
300+
301+
# Debug log for request
302+
logging.debug(f"[DEBUG] API Request: {self.endpoint.method.value} {self.endpoint.path}")
303+
logging.debug(f"[DEBUG] Request Data: {json.dumps(request_dict, indent=2)}")
304+
logging.debug(f"[DEBUG] Query Params: {self.endpoint.query_params}")
305+
306+
# Make the request
307+
resp = client.request(
308+
method=self.endpoint.method.value,
309+
path=self.endpoint.path,
310+
json=request_dict,
311+
params=self.endpoint.query_params,
312+
files=self.files,
313+
)
314+
315+
# Debug log for response
316+
logging.debug("=" * 50)
317+
logging.debug("[DEBUG] RESPONSE DETAILS:")
318+
logging.debug("[DEBUG] Status Code: 200 (Success)")
319+
logging.debug(f"[DEBUG] Response Body: {json.dumps(resp, indent=2)}")
320+
logging.debug("=" * 50)
321+
322+
# Parse and return the response
323+
return self._parse_response(resp)
324+
325+
except Exception as e:
326+
logging.debug(f"[DEBUG] API Exception: {str(e)}")
327+
raise Exception(str(e))
328+
329+
def _parse_response(self, resp):
330+
"""Parse response data - can be overridden by subclasses"""
331+
# The response is already the complete object, don't extract just the "data" field
332+
# as that would lose the outer structure (created timestamp, etc.)
333+
334+
# Parse response using the provided model
335+
self.response = self.endpoint.response_model.model_validate(resp)
336+
logging.debug(f"[DEBUG] Parsed Response: {self.response}")
337+
return self.response

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ kornia>=0.7.1
2323
spandrel
2424
soundfile
2525
av>=14.1.0
26+
pydantic~=2.0

server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,9 @@ def node_info(node_class):
580580
info['deprecated'] = True
581581
if getattr(obj_class, "EXPERIMENTAL", False):
582582
info['experimental'] = True
583+
584+
if hasattr(obj_class, 'API_NODE'):
585+
info['api_node'] = obj_class.API_NODE
583586
return info
584587

585588
@routes.get("/object_info")

0 commit comments

Comments
 (0)