try: from . import config from .utils.custom_dict import CustomDict from .utils.env_config import get_env_var except ImportError: import config from utils.custom_dict import CustomDict from utils.env_config import get_env_var NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} def litellm_base(cls): cls.CATEGORY = "ETK/LLM/LiteLLM" cls.FUNCTION = "handler" if "RETURN_TYPES" not in cls.__dict__: cls.RETURN_TYPES = ("STRING",) # Add spaces to the camel case class name pretty_name = cls.__name__ for i in range(1, len(pretty_name)): if pretty_name[i].isupper(): pretty_name = pretty_name[:i] + " " + pretty_name[i:] cls.DISPLAY_NAME = pretty_name NODE_CLASS_MAPPINGS[cls.__name__] = cls NODE_DISPLAY_NAME_MAPPINGS[cls.DISPLAY_NAME] = pretty_name return cls @litellm_base class HTMLRenderer: @classmethod def INPUT_TYPES(cls): return { "required": { "html_content": ("STRING", {"multiline": True}), "iframe_height": ("STRING", {"default": "800px"}), "iframe_width": ("STRING", {"default": "100%"}), }, } RETURN_TYPES = ("STRING",) FUNCTION = "handler" OUTPUT_NODE = True def handler(self, html_content, iframe_height, iframe_width): new_html_content = html_content.replace("\"", "'") new_html_content = f"""""" # Replace newlines and double quotes in the raw HTML ret = {"ui": {"string": [new_html_content]}, "result": (html_content,)} return ret import io import numpy as np @litellm_base class HTMLRendererScreenshot: @classmethod def INPUT_TYPES(cls): return { "required": { "html_content": ("STRING", {"multiline": True, "default": "

Hello World!

"}), "screenshot_base64": ("STRING", {"default": ""}), } } # We will return two things: # 1) An IMAGE => your screenshot as a float BHWC tensor # 2) A STRING => the actual iframe code (so the client can display the HTML) RETURN_TYPES = ("IMAGE", "STRING",) FUNCTION = "handler" OUTPUT_NODE = True NAME = "HTMLRendererScreenshot" def handler(self, html_content, screenshot_base64): magic = """setTimeout(() => { html2canvas(document.body, { allowTaint : true, logging: true, profile: true, useCORS: true }).then(function(canvas) { document.getElementById('screen').appendChild(canvas); }); }000);""" # 1) Create the iframe code from the user's HTML # We replace " to ' in the HTML to avoid messing up the srcdoc attribute. # safe_html = html_content.replace("'", '"') safe_html = html_content.replace('"', "'") iframe_code = """ """ # iframe_code = iframe_code.replace("\n", " ") # iframe_code = iframe_code.replace("\r", " ") # iframe_code = iframe_code.replace("\t", " ") iframe_code = iframe_code.replace("{safe_html}", safe_html) # 2) If we have no screenshot, return a 1×1 black image as a placeholder if not screenshot_base64.strip(): # shape = (batch=1, height=1, width=1, channels=3) arr = np.zeros((1, 1, 1, 3), dtype=np.float32) tensor = torch.from_numpy(arr) ret = {"ui": {"string": ["no image"]}, "result": (tensor, html_content,)} return ret # Otherwise, decode the base64 screenshot into a float [0..1] BHWC tensor raw_bytes = base64.b64decode(screenshot_base64) pil_img = Image.open(io.BytesIO(raw_bytes)).convert("RGB") arr = np.array(pil_img, dtype=np.float32) / 255.0 arr = np.expand_dims(arr, axis=0) # (1, H, W, 3) tensor = torch.from_numpy(arr).contiguous() # Return both the image and the iframe code # return (tensor, iframe_code) ret = {"ui": {"string": [iframe_code]}, "result": (tensor, html_content,)} return ret import markdown @litellm_base class MarkdownNode: """ A ComfyUI node that renders Markdown input as HTML in the content area while passing the raw Markdown string as output. """ def __init__(self): pass @classmethod def INPUT_TYPES(cls): return { "required": { "markdown_input": ("STRING", {"multiline": True, "default": "Enter Markdown text here..."}), }, } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("Markdown Output",) CATEGORY = "ETK\LLM\Markdown" def handler(self, markdown_input): # Render the Markdown to HTML rendered_html = markdown.markdown(markdown_input) # Return the required framework structure ret = { "ui": {"string": [rendered_html]}, # Rendered HTML for the UI "result": (markdown_input,), # Raw Markdown output } return ret @litellm_base class LiteLLMModelProviderAdv: # Hard-coded pricing (per 1M tokens) for known modern OpenAI models. # Pricing values are based on your provided table. OPENAI_MODEL_COSTS = { # GPT-4o and variants: "openai/gpt-4o": "Input: $2.50/1M, Cached: $1.25/1M, Output: $10.00/1M tokens", "openai/gpt-4o-2024-08-06": "Input: $2.50/1M, Cached: $1.25/1M, Output: $10.00/1M tokens", # GPT-4o audio-preview variants: "openai/gpt-4o-audio-preview": "Input: $2.50/1M, Output: $10.00/1M tokens", "openai/gpt-4o-audio-preview-2024-12-17": "Input: $2.50/1M, Output: $10.00/1M tokens", # GPT-4o realtime-preview variants: "openai/gpt-4o-realtime-preview": "Input: $5.00/1M, Cached: $2.50/1M, Output: $20.00/1M tokens", "openai/gpt-4o-realtime-preview-2024-12-17": "Input: $5.00/1M, Cached: $2.50/1M, Output: $20.00/1M tokens", # GPT-4o mini variants: "openai/gpt-4o-mini": "Input: $0.15/1M, Cached: $0.075/1M, Output: $0.60/1M tokens", "openai/gpt-4o-mini-2024-07-18": "Input: $0.15/1M, Cached: $0.075/1M, Output: $0.60/1M tokens", # GPT-4o mini audio-preview variants: "openai/gpt-4o-mini-audio-preview": "Input: $0.15/1M, Output: $0.60/1M tokens", "openai/gpt-4o-mini-audio-preview-2024-12-17": "Input: $0.15/1M, Output: $0.60/1M tokens", # GPT-4o mini realtime-preview variants: "openai/gpt-4o-mini-realtime-preview": "Input: $0.60/1M, Cached: $0.30/1M, Output: $2.40/1M tokens", "openai/gpt-4o-mini-realtime-preview-2024-12-17": "Input: $0.60/1M, Cached: $0.30/1M, Output: $2.40/1M tokens", # o1 variants: "openai/o1": "Input: $15.00/1M, Cached: $7.50/1M, Output: $60.00/1M tokens", "openai/o1-2024-12-17": "Input: $15.00/1M, Cached: $7.50/1M, Output: $60.00/1M tokens", # o1-mini variants: "openai/o1-mini": "Input: $1.10/1M, Cached: $0.55/1M, Output: $4.40/1M tokens", "openai/o1-mini-2024-09-12": "Input: $1.10/1M, Cached: $0.55/1M, Output: $4.40/1M tokens", # o3-mini variants: "openai/o3-mini": "Input: $1.10/1M, Cached: $0.55/1M, Output: $4.40/1M tokens", "openai/o3-mini-2025-01-31": "Input: $1.10/1M, Cached: $0.55/1M, Output: $4.40/1M tokens", # Also include GPT-3.5 Turbo and GPT-4 for completeness: "openai/gpt-3.5-turbo": "$2.00/1M tokens", "openai/gpt-3.5-turbo-16k": "Input: $3.00/1M, Output: $4.00/1M tokens", "openai/gpt-4": "Input: $30/1M, Output: $60/1M tokens (8K)", "openai/gpt-4-32k": "Input: $60/1M, Output: $120/1M tokens (32K)", # GPT-5 family pricing (approximate / subject to change): "openai/gpt-5": "Input: $10.00/1M, Cached: $5.00/1M, Output: $40.00/1M tokens", "openai/gpt-5-preview": "Input: $10.00/1M, Cached: $5.00/1M, Output: $40.00/1M tokens", "openai/gpt-5-mini": "Input: $1.00/1M, Cached: $0.50/1M, Output: $4.00/1M tokens", "openai/gpt-5-mini-preview": "Input: $1.00/1M, Cached: $0.50/1M, Output: $4.00/1M tokens", } # Default OpenAI models that should appear even when the API cannot be queried. DEFAULT_OPENAI_MODELS = sorted( { "openai/gpt-3.5-turbo", "openai/gpt-3.5-turbo-16k", "openai/gpt-4", "openai/gpt-4-32k", "openai/gpt-4o", "openai/gpt-4o-2024-08-06", "openai/gpt-4o-audio-preview", "openai/gpt-4o-audio-preview-2024-12-17", "openai/gpt-4o-mini", "openai/gpt-4o-mini-2024-07-18", "openai/gpt-4o-mini-audio-preview", "openai/gpt-4o-mini-audio-preview-2024-12-17", "openai/gpt-4o-mini-realtime-preview", "openai/gpt-4o-mini-realtime-preview-2024-12-17", "openai/gpt-4o-realtime-preview", "openai/gpt-4o-realtime-preview-2024-12-17", "openai/gpt-5", "openai/gpt-5-preview", "openai/gpt-5-mini", "openai/gpt-5-mini-preview", "openai/o1", "openai/o1-2024-12-17", "openai/o1-mini", "openai/o1-mini-2024-09-12", "openai/o3-mini", "openai/o3-mini-2025-01-31", } ) # Cache for metadata discovered while listing OpenAI models. _openai_model_metadata = {} # Models that typically require the Responses API style parameters even # when capability information is unavailable (e.g. fallback mode). _RESPONSES_PREFIXES = ( "o1", "o3", "gpt-4.1", "gpt-5", "gpt-4o-realtime", "gpt-4o-mini-realtime", "gpt-4o-audio", "gpt-4o-mini-audio", ) @classmethod def _normalize_openai_model_id(cls, model_id): if isinstance(model_id, dict): model_id = model_id.get("model") if not model_id: return None model_id = str(model_id).strip() if not model_id: return None if "/" not in model_id: return f"openai/{model_id}" return model_id @classmethod def _record_openai_model_metadata( cls, model_id, *, capabilities=None, requires_max_completion_tokens=None, ): normalized = cls._normalize_openai_model_id(model_id) if not normalized: return meta = cls._openai_model_metadata.get(normalized) if meta is None: meta = {"capabilities": None, "requires_max_completion_tokens": None} meta = dict(meta) if capabilities is not None: meta["capabilities"] = capabilities if requires_max_completion_tokens is not None: meta["requires_max_completion_tokens"] = bool( requires_max_completion_tokens ) cls._openai_model_metadata[normalized] = meta if normalized.startswith("openai/"): base_id = normalized.split("/", 1)[1] cls._openai_model_metadata[base_id] = meta @classmethod def _heuristic_requires_max_completion_tokens(cls, model_id): normalized = cls._normalize_openai_model_id(model_id) if not normalized or not normalized.startswith("openai/"): return False base_id = normalized.split("/", 1)[1].lower() for prefix in cls._RESPONSES_PREFIXES: if base_id.startswith(prefix): return True if "realtime" in base_id or "audio" in base_id: return True return False @classmethod def requires_max_completion_tokens(cls, model_id): normalized = cls._normalize_openai_model_id(model_id) if not normalized or not normalized.startswith("openai/"): return False meta = cls._openai_model_metadata.get(normalized) if meta: stored = meta.get("requires_max_completion_tokens") if stored is not None: return bool(stored) return cls._heuristic_requires_max_completion_tokens(normalized) @classmethod def _ensure_fallback_metadata(cls, models): for model in models: if cls._heuristic_requires_max_completion_tokens(model): cls._record_openai_model_metadata( model, requires_max_completion_tokens=True ) else: cls._record_openai_model_metadata(model) @classmethod def _get_openai_api_key(cls): """Return the best available API key for OpenAI requests.""" def _clean(value): if not value: return None value = value.strip() if not value or value.lower() == "your-api-key-here": return None return value key = _clean(get_env_var("OPENAI_API_KEY")) if key: return key return _clean(get_env_var("BASE_API_KEY")) @classmethod def _normalize_openai_base_url(cls, base_url): """Normalize configured OpenAI base URLs so litellm hits the right endpoint.""" if not base_url: return None base_url = str(base_url).strip() if not base_url: return None # Remove trailing slashes to avoid double separators when litellm appends paths. normalized = base_url.rstrip("/") # Ensure the canonical /v1 suffix exists for direct OpenAI endpoints. Avoid forcing # this when users point to compatible proxies that already expose the versioned # path as part of their URL. lower = normalized.lower() if lower.endswith("/v1") or lower.endswith("/v1beta"): return normalized if "openai" in lower and not lower.endswith("/v1"): return f"{normalized}/v1" return normalized @classmethod def _get_openai_base_url(cls): # Prefer explicit environment configuration before falling back to the config file. env_base = get_env_var("OPENAI_BASE_URL") base_url = env_base if env_base else config.config_settings.get("OPENAI_BASE_URL") if not base_url: # Default to the public OpenAI endpoint if nothing has been configured. base_url = "https://api.openai.com/v1" return cls._normalize_openai_base_url(base_url) @classmethod def _build_litellm_model(cls, model_id): """Construct a LITELLM_MODEL payload for the selected identifier.""" if not model_id: return model_id if not model_id.startswith("openai/"): return model_id kwargs = {} api_base = cls._get_openai_base_url() if api_base: kwargs["api_base"] = api_base api_key = cls._get_openai_api_key() if api_key: kwargs["api_key"] = api_key if not kwargs: return model_id return {"model": model_id, "type": "kwargs", "kwargs": kwargs} # Define the input types for the node; the displayed list is a list of strings with pricing info. @classmethod def INPUT_TYPES(cls): models = cls.get_all_model_display_strings() default = models[0] if models else "" return { "required": { "name": (models, {"default": default}), } } # Merge models fetched from OpenAI with hard-coded provider models. @classmethod def get_all_model_display_strings(cls): openai_ids = cls.get_openai_models() # Fetched OpenAI model IDs (list of strings) other_ids = cls.get_other_models() # Hard-coded models from other providers openai_display = [cls.format_model_display(model) for model in openai_ids] model_set = set(openai_ids) other_display = [] for model in other_ids: if model not in model_set: other_display.append(cls.format_model_display(model)) return openai_display + other_display # Format the display string for a model. @classmethod def format_model_display(cls, model): if model.startswith("openai/"): cost = cls.OPENAI_MODEL_COSTS.get(model, "cost unknown") return f"{model} ({cost})" else: return model # Fetch all OpenAI models using the new client instance. @classmethod def get_openai_models(cls): """Fetch a live list of OpenAI models. We call the Models API directly so that the list stays in sync with whatever is currently available to the configured account. Pagination is handled to make sure we do not miss models when OpenAI adds more. """ cls._openai_model_metadata = {} def _fallback_models(): # Use the union of pricing data and our curated default list as the # fallback so that headline models such as GPT-5 appear even when we # cannot query the API (e.g. missing API key or network failure). combined = set(cls.OPENAI_MODEL_COSTS.keys()) | set(cls.DEFAULT_OPENAI_MODELS) if not combined: return ["openai/gpt-4o-mini"] cls._ensure_fallback_metadata(combined) return sorted(combined) api_key = get_env_var("OPENAI_API_KEY") if not api_key or api_key.strip() == "" or api_key == "your-api-key-here": return _fallback_models() from openai import OpenAI base_url = config.config_settings.get("OPENAI_BASE_URL") normalized_base = cls._normalize_openai_base_url(base_url) if normalized_base: client = OpenAI(api_key=api_key, base_url=normalized_base) else: client = OpenAI(api_key=api_key) collected_models = [] seen = set() params = {"limit": 100} try: while True: response = client.models.list(**params) data = getattr(response, "data", []) or [] for model in data: model_id = getattr(model, "id", None) if model_id is None and isinstance(model, dict): model_id = model.get("id") if not model_id: continue # Skip obvious non-chat models and fine-tunes so the list # stays focused on completion-style models. if model_id.startswith("ft:"): continue # Prefer capability metadata when available to identify # chat-capable models. Fall back to id heuristics for older # responses that do not expose capabilities. capabilities = getattr(model, "capabilities", None) if isinstance(capabilities, dict): # The API may return booleans or truthy values for the # modalities. Any support for chat or text responses is # enough for this node. chat_capable = bool( capabilities.get("chat_completions") or capabilities.get("completions") or capabilities.get("responses") ) if not chat_capable: continue else: if not model_id.startswith(("gpt", "o", "omni")): continue display_id = model_id if model_id.startswith("openai/") else f"openai/{model_id}" requires_max_completion_tokens = False if isinstance(capabilities, dict): supports_chat = bool( capabilities.get("chat_completions") or capabilities.get("completions") ) supports_responses = bool(capabilities.get("responses")) requires_max_completion_tokens = ( supports_responses and not supports_chat ) else: requires_max_completion_tokens = cls._heuristic_requires_max_completion_tokens( display_id ) cls._record_openai_model_metadata( display_id, capabilities=capabilities if isinstance(capabilities, dict) else None, requires_max_completion_tokens=requires_max_completion_tokens, ) if display_id not in seen: seen.add(display_id) collected_models.append(display_id) if not getattr(response, "has_more", False): break last_id = getattr(response, "last_id", None) if not last_id and data: # Some deployments omit last_id; use the final model id so # pagination continues and we do not miss newer models # (e.g. GPT-5) that appear on later pages. last_entry = data[-1] last_id = getattr(last_entry, "id", None) if last_id is None and isinstance(last_entry, dict): last_id = last_entry.get("id") if last_id: params["after"] = last_id else: break if not collected_models: return _fallback_models() collected_models.sort() return collected_models except Exception as e: print(f"Warning: Could not fetch OpenAI models: {str(e)}") return _fallback_models() # Hard-coded list of models from other providers. @classmethod def get_other_models(cls): return [ "anthropic/claude-3-haiku-20240307", "anthropic/claude-3-sonnet-20240229", "anthropic/claude-3-opus-20240229", "anthropic/claude-3-5-sonnet-20240620", "bedrock/anthropic.claude-3-haiku-20240307-v1:0", "bedrock/anthropic.claude-3-sonnet-20240229-v1:0", "bedrock/anthropic.claude-3-opus-20240229-v1:0", "bedrock/anthropic.claude-3-5-sonnet-20240620", "vertex_ai/gemini-1.5-pro-preview-0514", "vertex_ai/gemini-1.5-flash-preview-0514", "vertex_ai/meta/llama3-405b-instruct-maas", "together_ai/deepseek-ai/DeepSeek-V3", "together_ai/meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo", ] # Define the return types for the node. RETURN_TYPES = ("LITELLM_MODEL",) RETURN_NAMES = ("Litellm model",) # Handler: Extract the actual model ID (by removing the pricing info) and return it. def handler(self, **kwargs): selected = kwargs.get("name", None) if selected and " (" in selected: model_id = selected.split(" (", 1)[0] else: model_id = selected return (self._build_litellm_model(model_id),) def _apply_openai_max_token_overrides(use_kwargs): """Normalize max token parameters for OpenAI responses-style models.""" model_name = use_kwargs.get("model") if LiteLLMModelProviderAdv.requires_max_completion_tokens(model_name): max_token_value = use_kwargs.pop("max_tokens", None) if max_token_value is not None: use_kwargs["max_completion_tokens"] = max_token_value use_kwargs.pop("temperature", None) use_kwargs.pop("top_p", None) use_kwargs.pop("logit_bias", None) return True use_kwargs.pop("reasoning_effort", None) return False @litellm_base class LiteLLMModelProvider: # Define the input types for the node @classmethod def INPUT_TYPES(cls): # Inputs can be used to customize the default architecture return { "required": { # ([True, False], {"default": True}) "name": ([ "anthropic/claude-3-haiku-20240307", "anthropic/claude-3-sonnet-20240229", "anthropic/claude-3-opus-20240229", "anthropic/claude-3-5-sonnet-20240620", "bedrock/anthropic.claude-3-haiku-20240307-v1:0", "bedrock/anthropic.claude-3-sonnet-20240229-v1:0", "bedrock/anthropic.claude-3-opus-20240229-v1:0", "bedrock/anthropic.claude-3-5-sonnet-20240620", "vertex_ai/gemini-1.5-pro-preview-0514", "vertex_ai/gemini-1.5-flash-preview-0514", "vertex_ai/meta/llama3-405b-instruct-maas", "openai/gpt-4o-mini", "openai/gpt-4o", "openai/gpt-4o-2024-08-06", "together_ai/deepseek-ai/DeepSeek-V3", "together_ai/meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo", ], {"default": "anthropic/claude-3-haiku-20240307"}), # "norm_zero_to_1": ([True, False], {"default": True}), } } # Define the return types RETURN_TYPES = ("LITELLM_MODEL",) RETURN_NAMES = ("Litellm model",) # Method to provide the default LiteLLM model def handler(self, **kwargs): # Get the model name from the input model_name = kwargs.get("name", None) return (model_name,) @litellm_base class LiteLLMCustomEndpointProvider: """ Custom Endpoint Provider for LiteLLM Allows configuration of custom API endpoints, API keys, and models for platforms like Kluster.ai, local servers, or other OpenAI-compatible APIs. """ @classmethod def INPUT_TYPES(cls): return { "required": { "model_name": ("STRING", {"default": "mistralai/Mistral-Nemo-Instruct-2407"}), "api_base": ("STRING", {"default": "https://api.kluster.ai/v1"}), "api_key": ("STRING", {"default": "your-api-key-here"}), "provider": (["openai", "anthropic", "cohere", "custom"], {"default": "openai"}), }, "optional": { "api_version": ("STRING", {"default": ""}), "organization": ("STRING", {"default": ""}), "timeout": ("INT", {"default": 60, "min": 1, "max": 300}), "max_retries": ("INT", {"default": 3, "min": 0, "max": 10}), } } RETURN_TYPES = ("LITELLM_MODEL",) RETURN_NAMES = ("model",) FUNCTION = "handler" CATEGORY = "ETK/LLM" def handler(self, model_name, api_base, api_key, provider, **kwargs): # Create model configuration with custom endpoint model_config = { "model": f"{provider}/{model_name}", "kwargs": { "api_key": api_key, "api_base": api_base, "timeout": kwargs.get("timeout", 60), "max_retries": kwargs.get("max_retries", 3), } } # Add optional parameters if provided if kwargs.get("api_version"): model_config["kwargs"]["api_version"] = kwargs["api_version"] if kwargs.get("organization"): model_config["kwargs"]["organization"] = kwargs["organization"] return (model_config,) # Fix the display name after the decorator has processed it LiteLLMCustomEndpointProvider.DISPLAY_NAME = "Custom Endpoint Provider" NODE_DISPLAY_NAME_MAPPINGS[LiteLLMCustomEndpointProvider.DISPLAY_NAME] = "Custom Endpoint Provider" @litellm_base class AddDataModelToLLLm: @classmethod def INPUT_TYPES(cls): return { "required": { "model": ("LITELLM_MODEL", {"default": "anthropic/claude-3-haiku-20240307"}), }, "optional": { "code": ( "STRING", {"default": "class UserModel(BaseModel):\n name: str= Field(..., description='The name of the " "person')\n age: int\n", "multiline": True}), "data_model": ("DATA_MODEL", {"default": None}), } } RETURN_TYPES = ("LITELLM_MODEL",) RETURN_NAMES = ("Litellm model",) def restricted_exec(self, code: str): from pydantic import BaseModel, Field, conlist from typing import Tuple, List, Dict, Set, Any, Callable, Union, Optional, Iterable, Iterator, Generator # Define a restricted global namespace restricted_globals = { "__builtins__": { # Basic functions "print": print, "range": range, "len": len, "__build_class__": __build_class__, # For class definitions # Primitive types "str": str, "int": int, "float": float, "bool": bool, "complex": complex, # Collection types "list": list, "dict": dict, "set": set, "tuple": tuple, "frozenset": frozenset, "bytes": bytes, "bytearray": bytearray, # Typing counterparts "Tuple": Tuple, "List": List, "Dict": Dict, "Set": Set, "Any": Any, "Callable": Callable, "Union": Union, "Optional": Optional, "Iterable": Iterable, "Iterator": Iterator, "Generator": Generator, }, "__name__": "__main__", # Define __name__ to simulate normal execution context "BaseModel": BaseModel, # Explicitly allow BaseModel from pydantic "Field": Field, # Explicitly allow Field to define field descriptions "conlist": conlist # Explicitly allow conlist to define list constraints } # Execute the code with restricted globals exec(code, restricted_globals) # Extract the created model from the restricted_globals # Assuming the user will name their model 'UserModel' user_model = restricted_globals.get("UserModel") if user_model is None: raise ValueError("No model named 'UserModel' was defined in the provided code.") return user_model def handler(self, model, code=None, data_model=None): use_data_model = None # first figure out the data_model if data_model is not None: use_data_model = data_model elif isinstance(code, str): use_data_model = self.restricted_exec(code) if isinstance(model, dict): model["kwargs"]["response_format"] = use_data_model elif isinstance(model, str): model = {"model": model, "type": "kwargs", "kwargs": {"response_format": use_data_model} } return (model,) @litellm_base class ModifyModelKwargs: @classmethod def INPUT_TYPES(cls): return { "required": { "model": ("LITELLM_MODEL", {"default": "anthropic/claude-3-haiku-20240307"}), }, "optional": { "kwargs": ("DICT", {"default": {}}), "json_str_of_kwargs": ("STRING", {"default": "{}", "multiline": True}), } } RETURN_TYPES = ("LITELLM_MODEL",) RETURN_NAMES = ("Litellm model",) def handler(self, model, kwargs={}, json_str_of_kwargs={}, data_model=None): import json if json_str_of_kwargs != "{}": kwargs = json.loads(json_str_of_kwargs) if "logit_bias" in kwargs: if isinstance(kwargs["logit_bias"], list): l_logits = kwargs["logit_bias"] set_logits = dict() for id, p in l_logits: set_logits[str(id)] = int(p) kwargs["logit_bias"] = set_logits if isinstance(model, dict): model["kwargs"].update(kwargs) elif isinstance(model, str): model = {"model": model, "type": "kwargs", "kwargs": kwargs} return (model,) @litellm_base class LiteLLMCompletion: @classmethod def INPUT_TYPES(cls): return { "required": { "model": ("LITELLM_MODEL", {"default": "anthropic/claude-3-haiku-20240307"}), "max_tokens": ("INT", {"default": 250, "min": 1, "max": 1e10, "step": 1}), "temperature": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}), "top_p": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}), "frequency_penalty": ("FLOAT", {"default": 0}), "presence_penalty": ("FLOAT", {"default": 0}), "prompt": ("STRING", {"default": "Hello World!", "multiline": True}), "reasoning_effort": (["low", "medium", "high"], {"default": "low"}), }, "optional": { "messages": ("LLLM_MESSAGES", {"default": None}), "use_cached_response": ("BOOLEAN", {"default": False}), } } RETURN_TYPES = ("LITELLM_MODEL", "LLLM_MESSAGES", "STRING", "STRING",) RETURN_NAMES = ("Model", "Messages", "Completion", "Usage",) def handler(self, **kwargs): from copy import deepcopy litellm.drop_params = True kwargs = deepcopy(kwargs) model = kwargs.get('model', 'anthropic/claude-3-haiku-20240307') out_model = deepcopy(kwargs.get('model', 'anthropic/claude-3-haiku-20240307')) if kwargs["top_p"] == 0: kwargs["top_p"] = None if kwargs["temperature"] == 0: kwargs["temperature"] = None if kwargs["frequency_penalty"] == 0: kwargs["frequency_penalty"] = None if kwargs["presence_penalty"] == 0: kwargs["presence_penalty"] = None reasoning_effort = kwargs.get('reasoning_effort', "low") if isinstance(kwargs["model"], dict): _model = model.get("model", "anthropic/claude-3-haiku-20240307") _type = model.get("type", None) if _type: if _type == "kwargs": _kwargs = model.get("kwargs", {}) kwargs.update(_kwargs) kwargs["model"] = _model model = _model messages = kwargs.get('messages', []) messages = deepcopy(messages) max_tokens = kwargs.get('max_tokens', None) temperature = kwargs.get('temperature', None) top_p = kwargs.get('top_p', None) frequency_penalty = kwargs.get('frequency_penalty', None) presence_penalty = kwargs.get('presence_penalty', None) prompt = kwargs.pop('prompt', "Hello World!") use_cached_response = kwargs.get('use_cached_response', False) messages.append({"content": prompt, "role": "user"}) kwargs["messages"] = messages import hashlib kwargs.pop('use_cached_response', None) uid_kwargs = CustomDict() uid_kwargs.update(kwargs.copy()) uid_kwargs["prompt"] = prompt uid_kwargs = str(uid_kwargs.every_value_str()) unique_id = str(hashlib.sha256(json.dumps(uid_kwargs).encode()).hexdigest()) del uid_kwargs cache_file_name = f'cached_response_{unique_id}.json' cache_file_path = os.path.join(config.config_settings['tmp_dir'], cache_file_name) response = None cached_completion = False if use_cached_response and os.path.exists(cache_file_path): with open(cache_file_path, 'r') as file: response = json.load(file) response = litellm.ModelResponse(**response) cached_completion = True if not response: while not response: try: litellm.set_verbose: True n_kwargs = { "model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature, "top_p": top_p, "frequency_penalty": frequency_penalty, "presence_penalty": presence_penalty, "reasoning_effort": reasoning_effort, } n_kwargs.update(kwargs) if _apply_openai_max_token_overrides(n_kwargs): print("OpenAI responses-style model detected") else: print("Standard completion model detected") response = litellm.completion( **n_kwargs ) except litellm.exceptions.RateLimitError as e: print(f"Rate limit error: {e}") time.sleep(5) continue except Exception as e: print(f"An error occurred: {e}") raise response_choices = response.choices response_first_choice = response_choices[0] response_first_choice_message = response_first_choice.message response_content = response_first_choice_message.content or "" if not os.path.exists(config.config_settings['tmp_dir']): os.makedirs(config.config_settings['tmp_dir']) if os.path.exists(cache_file_path): os.remove(cache_file_path) with open(cache_file_path, 'w') as file: json.dump(response.json(), file, indent=4) messages.append({"content": response_content, "role": response_first_choice_message.role}) try: d = response.usage.model_extra lines = [f"{k}:{v}" for k, v in d.items()] usage = "\n".join(lines) except Exception as e: usage = "" if cached_completion: top = "cached response used\n" top = f"\033[1;32;40m{top}\033[0m" usage = top + usage else: top = "new result generated\n" top = f"\033[1;31;40m{top}\033[0m" usage = top + usage return (out_model, messages, response_content, usage,) import os import json import hashlib import time import base64 from copy import deepcopy import litellm api_base = config.config_settings.get("OPENAI_BASE_URL", "https://api.openai.com/v1/") key = get_env_var("BASE_API_KEY") if api_base: litellm.api_base = api_base if key: litellm.api_key = key litellm.set_verbose=True import torch from PIL import Image from io import BytesIO @litellm_base class LitellmCompletionV2: @classmethod def get_valid_tasks(cls): # Ideally, fetch this list dynamically from LiteLLM documentation or API return ["transcription", "classification", "completion", "translation", "summarization", "image_captioning", "object_detection"] @classmethod def INPUT_TYPES(cls): valid_tasks = cls.get_valid_tasks() default = LiteLLMCompletion.INPUT_TYPES()["required"] default["task"] = (valid_tasks, {"default": "completion"}) out = { "required": default, "optional": { "image": ("IMAGE", {"default": None}), # Assuming image_tensor is the PyTorch tensor "messages": ("LLLM_MESSAGES", {"default": None}), "use_cached_response": ("BOOLEAN", {"default": False}), } } return out RETURN_TYPES = ("LITELLM_MODEL", "LLLM_MESSAGES", "STRING", "LIST", "STRING",) RETURN_NAMES = ("Model", "Messages", "Completion", "[Completions]", "Usage",) def tensor_image_to_base64(self, tensor): tensor = tensor.mul(255).byte() # Convert to 0-255 image = Image.fromarray(tensor.numpy()) buffer = BytesIO() image.save(buffer, format="JPEG", quality=100) return base64.b64encode(buffer.getvalue()).decode("utf-8") def tensor_image_stack_to_base64(self, tensor): b, h, w, c = tensor.shape out = [] for i in range(b): img = self.tensor_image_to_base64(tensor[i]) img_data = self.get_image_data(img) out.append(img_data) return out def get_image_data(self, base64_image): image_data = { "type": "image_url", "image_url": { "url": "data:image/jpeg;base64," + base64_image } } return image_data def handler(self, **kwargs): from pydantic import BaseModel from copy import deepcopy kwargs = deepcopy(kwargs) litellm.drop_params = True single_response = True task = kwargs.get('task', "transcription") image = kwargs.get('image', None) # Validate the task valid_tasks = self.get_valid_tasks() if task not in valid_tasks: raise ValueError(f"Invalid task '{task}'. Valid tasks are: {valid_tasks}") # Optional parameters handling if kwargs.get("top_p", 0) == 0: kwargs["top_p"] = 0 if kwargs.get("temperature", 0) == 0: kwargs["temperature"] = 0 if kwargs.get("frequency_penalty", 0) == 0: kwargs["frequency_penalty"] = 0 if kwargs.get("presence_penalty", 0) == 0: kwargs["presence_penalty"] = 0 prompt = kwargs.get('prompt', "Hello World!") # Get input parameters model = kwargs.get('model', 'anthropic/claude-3.5-sonnet') messages = kwargs.get('messages', []) messages.append({"role": "user", "content": prompt}) messages = deepcopy(messages) max_tokens = kwargs.get('max_tokens', None) temperature = kwargs.get('temperature', None) top_p = kwargs.get('top_p', None) frequency_penalty = kwargs.get('frequency_penalty', None) presence_penalty = kwargs.get('presence_penalty', None) use_cached_response = kwargs.get('use_cached_response', False) # Cache handling setup kwargs.pop('use_cached_response', None) kwargs.pop('image', None) mdl_cls = None if isinstance(kwargs["model"], dict): if "response_format" in kwargs["model"]["kwargs"]: # if kwargs["model"]["kwargs"]["response_format"].__name__ == "UserModel": mdl_cls = kwargs["model"]["kwargs"]["response_format"] if isinstance(mdl_cls, dict): kwargs["model"]["kwargs"]["response_format"] = mdl_cls else: kwargs["model"]["kwargs"]["response_format"] = mdl_cls.schema_json() unique_id = str(hashlib.sha256(json.dumps(kwargs).encode()).hexdigest()) if mdl_cls: kwargs["model"]["kwargs"]["response_format"] = mdl_cls cache_file_name = f'cached_response_{unique_id}.json' cache_file_path = os.path.join(config.config_settings['tmp_dir'], cache_file_name) response = None cached_completion = False responses = None # Check for cached response if use_cached_response and os.path.exists(cache_file_path): try: with open(cache_file_path, 'r') as file: responses = json.load(file) responses_out = [] if isinstance(responses, list): for r in responses: responses_out.append(litellm.ModelResponse(**r)) responses = responses_out cached_completion = True else: responses = None print("cached response not a list.") except Exception as e: print(f"Error loading cached response: {e}") responses = None base64_images = [None] # Encode image to base64 if the task requires image processing if task in ["transcription", "classification", "image_captioning", "object_detection"]: base64_images = self.tensor_image_stack_to_base64(image) if len(base64_images) > 1: single_response = False if not responses: responses = [] for id in base64_images: response = None response_content = "" response_first_choice_message = None while not response: try: if id: if id is not None: response = self.litellm_completion_v2_inner( frequency_penalty = frequency_penalty, max_tokens = max_tokens, messages = messages, model = model, presence_penalty = presence_penalty, prompt=prompt, task=task, temperature=temperature, top_p=top_p, image_data=id ) else: response = self.litellm_completion_v2_inner( frequency_penalty, max_tokens, messages, model, presence_penalty, prompt, task, temperature, top_p ) if response: response_choices = response.choices response_first_choice = response_choices[0] response_first_choice_message = response_first_choice.message response_content = response_first_choice_message.content or "" responses.append(response_content) except litellm.BadRequestError as e: print(f"Bad request error: {e}") response_content = f"Error: {e}" responses.append(response_content) break except Exception as e: print(f"An error occurred: {e}") response_content = f"Error: {e}" responses.append(response_content) break if not os.path.exists(config.config_settings['tmp_dir']): os.makedirs(config.config_settings['tmp_dir']) if os.path.exists(cache_file_path): os.remove(cache_file_path) with open(cache_file_path, 'w') as file: json.dump(responses, file, indent=4) usage = "" if single_response and response_first_choice_message: messages.append({"content": response_content, "role": response_first_choice_message.role}) try: d = response.usage.model_extra lines = [f"{k}:{v}" for k, v in d.items()] usage = "\n".join(lines) except Exception as e: usage = "" if cached_completion: top = "cached response used\n" top = f"\033[1;32;40m{top}\033[0m" usage = top + usage else: top = "new result generated\n" top = f"\033[1;31;40m{top}\033[0m" usage = top + usage return (model, messages, responses[0], responses, usage,) def litellm_completion_v2_inner(self, frequency_penalty, max_tokens, messages, model, presence_penalty, prompt, reasoning_effort="low", task="completion", temperature=1.0, top_p=1.0, image_data=None): from copy import deepcopy import json # deepcopy all the inputs frequency_penalty = deepcopy(frequency_penalty) presence_penalty = deepcopy(presence_penalty) max_tokens = deepcopy(max_tokens) messages = deepcopy(messages) model = deepcopy(model) presence_penalty = deepcopy(presence_penalty) prompt = deepcopy(prompt) task = deepcopy(task) temperature = deepcopy(temperature) top_p = deepcopy(top_p) try: litellm.set_verbose = True if task in ["transcription", "classification", "image_captioning", "object_detection"]: # Tasks that require vision API if image_data: last_message = messages[-1] if isinstance(last_message["content"], str): message_type = "text" new_message = {"type": "text", "text": last_message["content"]} last_message["content"] = [new_message] else: message_type = last_message["content"][-1]["type"] if message_type == "text": last_message["content"].append(image_data) use_kwargs = { "model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature, "top_p": top_p, "frequency_penalty": frequency_penalty, "presence_penalty": presence_penalty, "reasoning_effort": reasoning_effort, } # if isinstance(model, dict): # if model[model] # else: # if ("o1" in model) or ("o3" in model): # o_model=True # else: # o_model=False if _apply_openai_max_token_overrides(use_kwargs): print("OpenAI responses-style model detected") else: print("Standard completion model detected") print(list(use_kwargs.keys())) response = litellm.completion(**use_kwargs) else: # Other tasks base_schema = { "response_format": { "type": "json_schema", "json_schema": { "name": "user_model", "strict": True, "schema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "additionalProperties": False } } } } use_kwargs = { "model": model, "messages": messages, "stream": False, "max_tokens": max_tokens, "temperature": temperature, "top_p": top_p, "frequency_penalty": frequency_penalty, "presence_penalty": presence_penalty, "prompt": prompt } if isinstance(model, dict): if "kwargs" in model: # Extract and update with custom kwargs (api_key, api_base, etc.) use_kwargs.update(model["kwargs"]) # Always fix model string when we have a dict with kwargs use_kwargs["model"] = model["model"] if "response_format" in use_kwargs: base_scm_use = deepcopy(base_schema) if hasattr(use_kwargs["response_format"], "schema"): scm = use_kwargs["response_format"].schema() base_scm_use["response_format"]["json_schema"]["schema"].update(scm) use_kwargs["response_format"] = json.dumps(base_scm_use["response_format"]) if isinstance(use_kwargs["response_format"], str): ob = json.loads(use_kwargs["response_format"]) else: ob = use_kwargs["response_format"] if "$defs" in ob["json_schema"]["schema"]: defs = ob["json_schema"]["schema"]["$defs"] for k, v in defs.items(): v["additionalProperties"] = False use_kwargs["response_format"] = ob use_kwargs.pop("prompt", None) if _apply_openai_max_token_overrides(use_kwargs): print("OpenAI responses-style model detected") else: print("Standard completion model detected") print(list(use_kwargs.keys())) response = litellm.completion( **use_kwargs ) except litellm.exceptions.RateLimitError as e: print(f"Rate limit error: {e}") time.sleep(5) except Exception as e: print(f"An error occurred: {e}") raise return response @litellm_base class PDFToImageNode: @classmethod def INPUT_TYPES(cls): return { "required": { "pdf_path": ("STRING", {"default": "path/to/your/pdf/file.pdf"}), "dpi": ("INT", {"default": 200, "min": 72, "max": 600}), # Page numbers are 1-based for user input consistency "first_page": ("INT", {"default": 1, "min": 1}), # -1 or 0 means process until the end "last_page": ("INT", {"default": -1}), }, } # Output is a batch of images suitable for ComfyUI IMAGE type RETURN_TYPES = ("IMAGE",) FUNCTION = "handler" # Function to execute def handler(self, pdf_path, dpi, first_page, last_page): import pymupdf,sys if not os.path.exists(pdf_path): raise FileNotFoundError(f"PDF file not found: {pdf_path}") page_tensors = [] doc = None # Initialize doc to None for finally block try: # 1. Open PDF with PyMuPDF doc = pymupdf.open(pdf_path) num_pages = doc.page_count # 2. Determine page range (0-based index for PyMuPDF) start_index = max(0, first_page - 1) # Handle last_page: <=0 means 'to the end' if last_page <= 0 or last_page > num_pages: end_index = num_pages - 1 else: end_index = last_page - 1 # Convert 1-based to 0-based # Check for invalid range if start_index >= num_pages or start_index > end_index: raise ValueError(f"Invalid page range ({first_page}-{last_page}) for PDF with {num_pages} pages.") # 3. Calculate zoom factor from DPI (base is 72 DPI for PDFs) zoom_factor = dpi / 72.0 render_matrix = pymupdf.Matrix(zoom_factor, zoom_factor) # 4. Iterate through the specified page range for i in range(start_index, end_index + 1): page = doc.load_page(i) # 5. Render page to RGB pixmap using get_pixmap # alpha=False ensures 3 channels (RGB) pix = page.get_pixmap(matrix=render_matrix, alpha=False) height, width, channels = pix.height, pix.width, pix.n # Ensure we got an RGB image (3 channels) if channels != 3: print(f"Warning: Page {i+1} rendered with {channels} channels (expected 3 for RGB). Skipping page.", file=sys.stderr) # Attempt conversion if RGBA? For simplicity, we skip non-RGB here. # If RGBA (channels=4), you could potentially slice: np_array = np_array[:, :, :3] continue # 6. Convert pixmap samples to NumPy array # samples are bytes: height * width * channels np_array = np.frombuffer(pix.samples, dtype=np.uint8).reshape(height, width, channels) # 7. Convert NumPy array to PyTorch Tensor (float32, normalized 0-1) # from_numpy avoids data copy where possible page_tensor = torch.from_numpy(np_array).float().div(255.0) # Shape: (H, W, C) page_tensors.append(page_tensor) except Exception as e: raise RuntimeError(f"Error processing PDF '{pdf_path}': {e}") from e finally: # Ensure the document is closed even if errors occur if doc: doc.close() # Check if any pages were processed if not page_tensors: # Raise error or return specific empty tensor? Framework dependent. # Raising error is often clearer than returning ambiguous empty tensor. raise ValueError(f"No pages were successfully rendered for PDF '{pdf_path}' in the specified range.") # 8. Stack page tensors into a batch tensor (B, H, W, C) try: # torch.stack adds the batch dimension (B) batch_images = torch.stack(page_tensors, dim=0) except RuntimeError as stack_error: # This error typically occurs if pages have different dimensions # even when rendered at the same DPI (e.g., different aspect ratios). raise RuntimeError( f"Failed to stack page images for '{pdf_path}'. " f"Pages might have different dimensions after rendering at {dpi} DPI. Error: {stack_error}" ) from stack_error # Return the stacked tensor in a tuple, as expected by ComfyUI return (batch_images,) @litellm_base class ShowLastMessage: @classmethod def INPUT_TYPES(s): return { "required": { "messages": ("LLLM_MESSAGES", {"default": "Something to show..."}), }, "optional": { "list_display": ("STRING", {"multiline": True, "default": ""}), }, } INPUT_IS_LIST = False RETURN_TYPES = ("LLLM_MESSAGES",) RETURN_NAMES = ("messages",) # INTERNAL_STATE_DISPLAY = "list_display" # THIS IS BRYTHON CODE # INTERNAL_STATE = "" FUNCTION = "handler" OUTPUT_NODE = True # OUTPUT_IS_LIST = (True,) description = "Show list in browser" CATEGORY = "utils" def handler(self, **kwargs): from copy import deepcopy kwargs = deepcopy(kwargs) lst = kwargs.get("messages", None) text_d = [] d = lst[-1] who = d.get("role") content = d.get("content") s = "" s += f"{who.upper()}:\n" s += f"{content}\n" s += "\n" text_d.append(s) res = lst out_list = ["\n".join(text_d)] ret = {"ui": {"string": out_list}, "result": (res,)} return ret @litellm_base class ShowMessages: @classmethod def INPUT_TYPES(s): return { "required": { "messages": ("LLLM_MESSAGES", {"default": "Something to show..."}), }, "optional": { "list_display": ("STRING", {"multiline": True, "default": ""}), }, } INPUT_IS_LIST = False RETURN_TYPES = ("LLLM_MESSAGES",) RETURN_NAMES = ("messages",) INTERNAL_STATE_DISPLAY = "list_display" # THIS IS BRYTHON CODE INTERNAL_STATE = "" FUNCTION = "handler" OUTPUT_NODE = True # OUTPUT_IS_LIST = (True,) description = "Show list in browser" CATEGORY = "utils" def handler(self, **kwargs): from copy import deepcopy kwargs = deepcopy(kwargs) """ input is list : List[List[str]] Returns a dict to be displayed by the UI and passed to the next function output like ret["ui"]["list"] needs to be a list of strings output like ret["result"] should mirror the input >>> ShowList.notify([["Hello", "world"], ["How", "are", "you?"]]) {'ui': {'list': ["Hello", "world", "How", "are", "you?"]}, 'result': [["Hello", "world"], ["How", "are", "you?"]]} >>> ShowList.notify([]) {'ui': {'list': []}, 'result': []} """ lst = kwargs.get("messages", None) text_d = [] for d in lst: who = d.get("role") content = d.get("content") s = "" s += f"{who.upper()}:\n" s += f"{content}\n" s += "\n" text_d.append(s) res = lst out_list = ["\n".join(text_d)] ret = {"ui": {"string": out_list}, "result": (res,)} return ret @litellm_base class LiteLLMCompletionPrePend(LiteLLMCompletion): """just like LiteLLMCompletion but with a pre-pended prompt""" @classmethod def INPUT_TYPES(cls): base = LiteLLMCompletion.INPUT_TYPES() base["required"]["pre_prompt"] = ("STRING", {"default": "pre", "multiline": True}) _ = base["required"].pop("prompt") base["required"]["prompt"] = ("STRING", {"default": "prompt", "multiline": True}) return base RETURN_TYPES = LiteLLMCompletion.RETURN_TYPES def handler(self, **kwargs): pp = kwargs.pop("pre_prompt", None) kwargs["prompt"] = f"{pp}\n{kwargs['prompt']}" return super().handler(**kwargs) @litellm_base @litellm_base class LiteLLMCompletionListOfPrompts: """Calls LiteLLMCompletion for each item. If 'prompts' list is provided, it iterates through prompts, using corresponding message history (optional) and prepending pre_prompt to each prompt. If 'prompts' is empty/None, it iterates through the 'messages' list, using pre_prompt as the new user message content for each history. Returns completions and resulting messages lists.""" @classmethod def INPUT_TYPES(cls): # Use the definition from the provided LiteLLMCompletion base = LiteLLMCompletion.INPUT_TYPES() # Modify for list processing: Set up required fields if "required" in base and "prompt" in base["required"]: base["required"]["pre_prompt"] = base["required"].pop("prompt") elif "required" not in base: base["required"] = {} base["required"]["pre_prompt"] = ("STRING", {"multiline": True, "default": "Hello World!"}) base["required"]["prompts"] = ("LIST", {"default": None}) base["required"]["async"] = ("BOOLEAN", {"default": True}) base["optional"]["messages"] =("LIST", {"default": None}) return base # Return types remain the same RETURN_TYPES = ("LITELLM_MODEL", "LIST", "LIST", "STRING",) RETURN_NAMES = ("Model", "Completions", "Messages", "Usage",) # Helper function remains the same def wrapped_llm_call(self, index, **kwargs): return (index, LiteLLMCompletion().handler(**kwargs)) # Core async processing function remains the same async def async_process_prompt(self, index, prompt, pre_prompt, message_history, **kwargs): import asyncio import functools combined_prompt = f"{pre_prompt}\n{prompt}" if pre_prompt else prompt kwargs["messages"] = message_history kwargs["prompt"] = combined_prompt loop = asyncio.get_running_loop() partial_func = functools.partial(self.wrapped_llm_call, index, **kwargs) idx, the_rest = await loop.run_in_executor(None, partial_func) model, resulting_messages, completion, usage = the_rest return idx, resulting_messages, completion # Core sync processing function remains the same def process_prompt(self, prompt, pre_prompt, message_history, **kwargs): combined_prompt = f"{pre_prompt}\n{prompt}" if pre_prompt else prompt kwargs["messages"] = message_history kwargs["prompt"] = combined_prompt model, resulting_messages, completion, usage = LiteLLMCompletion().handler(**kwargs) return resulting_messages, completion # Modified async aggregation to handle the two modes async def process_prompts(self, prompts, pre_prompt, messages_input_list, use_pre_prompt_as_prompt=False, **kwargs): import asyncio # Clean kwargs before passing down kwargs.pop("pre_prompt", None) kwargs.pop("prompts", None) kwargs.pop("messages", None) kwargs.pop("async", None) tasks = [] if use_pre_prompt_as_prompt: # Mode 2: Iterate through messages, use pre_prompt as the new prompt content print("Info: No prompts provided, iterating through message histories using pre_prompt.") for i, message_history in enumerate(messages_input_list): tasks.append( self.async_process_prompt( i, prompt=pre_prompt, # Use pre_prompt as the main content pre_prompt="", # No additional pre_prompt needed here message_history=message_history, **kwargs ) ) else: # Mode 1: Original logic - iterate through prompts print(f"Info: Processing {len(prompts)} prompts.") for i, prompt in enumerate(prompts): tasks.append( self.async_process_prompt( i, prompt=prompt, pre_prompt=pre_prompt, message_history=messages_input_list[i], # Assumes validation happened in handler **kwargs ) ) if not tasks: return [], [] # Return empty if no tasks generated results = await asyncio.gather(*tasks) # List of (idx, resulting_messages, completion) sorted_results = sorted(results, key=lambda x: x[0]) completions_list = [completion for idx, messages, completion in sorted_results] messages_list = [messages for idx, messages, completion in sorted_results] return completions_list, messages_list def handler(self, **kwargs): import asyncio import sys prompts = kwargs.pop("prompts", None) # Pop prompts, default None pre_prompt = kwargs.pop("pre_prompt", "") messages_input_list = kwargs.pop("messages", None) # Pop optional messages run_async = kwargs.pop("async", True) completions_list = [] messages_output_list = [] usage_str = "" model_param = kwargs.get("model") # --- Determine Mode and Validate Inputs --- process_using_prompts = bool(prompts and isinstance(prompts, list) and len(prompts) > 0) process_using_messages = bool(messages_input_list and isinstance(messages_input_list, list) and len(messages_input_list) > 0) iterator_list = [] # The list we will actually loop over mode_kwargs = {} # kwargs specific to the mode for process_prompts message_histories_to_use = [] if process_using_prompts: iterator_list = prompts mode_kwargs['use_pre_prompt_as_prompt'] = False # Validate messages length against prompts length or default if not process_using_messages or len(messages_input_list) != len(prompts): print(f"Warning: Input 'messages' list is None or length mismatch with 'prompts'. Using empty history for prompts.", file=sys.stderr) message_histories_to_use = [[] for _ in prompts] else: message_histories_to_use = [hist if isinstance(hist, list) else [] for hist in messages_input_list] elif process_using_messages: # No valid prompts, but valid messages list iterator_list = messages_input_list # We iterate based on message histories mode_kwargs['use_pre_prompt_as_prompt'] = True message_histories_to_use = [hist if isinstance(hist, list) else [] for hist in messages_input_list] # Validate inner lists if not pre_prompt: print("Warning: No prompts provided, iterating message histories, but 'pre_prompt' is empty. LLM calls might lack new input.", file=sys.stderr) else: # Neither prompts nor messages are valid lists print("Error: No valid 'prompts' list or 'messages' list provided. Nothing to process.", file=sys.stderr) return (model_param, [], [], usage_str,) # Return empty results # --- End Mode Determination --- if run_async: running_loop = None try: running_loop = asyncio.get_running_loop() except RuntimeError: running_loop = None if running_loop and running_loop.is_running(): print( "Warning: Async processing requested while an event loop is already running." " Falling back to synchronous processing.", file=sys.stderr, ) run_async = False if run_async: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: # Pass necessary lists and the mode flag completions_list, messages_output_list = loop.run_until_complete( self.process_prompts( prompts if process_using_prompts else [], # Pass original prompts if relevant pre_prompt, message_histories_to_use, # Pass the validated/defaulted list **mode_kwargs, # Contains use_pre_prompt_as_prompt flag **kwargs # Pass remaining LLM params ) ) finally: loop.close() else: # Sync Path if process_using_prompts: # Iterate through prompts for i, prompt in enumerate(iterator_list): message_history = message_histories_to_use[i] resulting_messages, completion = self.process_prompt( prompt=prompt, pre_prompt=pre_prompt, message_history=message_history, **kwargs ) messages_output_list.append(resulting_messages) completions_list.append(completion) elif process_using_messages: # Iterate through message histories, use pre_prompt as prompt content for i, message_history in enumerate(iterator_list): resulting_messages, completion = self.process_prompt( prompt=pre_prompt, # Use pre_prompt as the new content pre_prompt="", # No secondary pre_prompt needed message_history=message_history, **kwargs ) messages_output_list.append(resulting_messages) completions_list.append(completion) return (model_param, completions_list, messages_output_list, usage_str,) @litellm_base class CreateReflectionFilter: """ this creates a callable that can be used in the LiteLLMCompletionWithReflectionFilter node the inputs are another callable and args and kwargs the returned callable will call the input callable with the args and kwargs """ @classmethod def INPUT_TYPES(cls): return { "required": { "CALLABLE": ("CALLABLE", {"default": None}), }, "optional": { "args": ("LIST", {"default": []}), "kwargs": ("DICT", {"default": {}}), }, } RETURN_TYPES = ("CALLABLE",) RETURN_NAMES = ("Reflection filter",) def handler(self, CALLABLE: callable, args: list = [], kwargs: dict = {}): _args = args def reflection_filter(completion, *__args, **kwargs): __args = [completion] + _args return CALLABLE(completion, *__args, **kwargs) return (reflection_filter,) @litellm_base class FirstCodeBlockReflectionFilter: """ this is designed to work as a CALLABLE that can be chained with the CreateReflectionFilter node it proveds a callable that will return the first code block in its first input """ @classmethod def INPUT_TYPES(cls): return { "required": { "CALLABLE": ("CALLABLE", {"default": None}), }, "optional": { "args": ("LIST", {"default": []}), "kwargs": ("DICT", {"default": {}}), }, } RETURN_TYPES = ("CALLABLE",) RETURN_NAMES = ("callable",) def handler(self, CALLABLE: callable, args: list = [], kwargs: dict = {}): _args = args def code_block_filter(completion, *__args, **kwargs): if "```" not in completion: return completion completion = completion.split("```")[1] if completion.startswith("python"): completion = completion.split("python")[1] completion = completion.split("```")[0] __args = [completion] + _args return CALLABLE(completion, *__args, **kwargs) return (code_block_filter,) @litellm_base class LiteLLMCompletionWithReflectionFilter: """ this calls LiteLLMCompletion but takes an optional parameter that is a callable the callable is called with its only input being the response from the completion if the callable returns None, the response is returned as is if the callable returns a string, the string is used as the prompt for the next completion this continues until the required paramater max_iterations is reached or the callable returns None """ @classmethod def INPUT_TYPES(cls): # grab the input types from the LiteLLMCompletion node input_types = LiteLLMCompletion.INPUT_TYPES() # add the reflection_filter input input_types["optional"]["reflection_filter"] = ("CALLABLE", {"default": None}) # add the max_iterations input input_types["required"]["max_iterations"] = ("INT", {"default": 10}) return input_types RETURN_TYPES = ("LITELLM_MODEL", "LLLM_MESSAGES", "STRING", "STRING",) RETURN_NAMES = ("Model", "Messages", "Completion", "Usage",) # Method to provide the default LiteLLM model def handler(self, **kwargs): # grab the handler from the LiteLLMCompletion node completion_handler = LiteLLMCompletion().handler # pop the reflection_filter from the kwargs reflection_filter = kwargs.pop("reflection_filter", None) # pop the max_iterations from the kwargs max_iterations = kwargs.pop("max_iterations", 10) # call the handler collect the resutls model, messages, completion, usage = completion_handler(**kwargs) # set the current iteration to 0 current_iteration = 0 # while the current iteration is less than the max_iterations while current_iteration < max_iterations: # if there is no reflection filter, break the loop if not reflection_filter: break # call the reflection filter with the completion new_prompt = reflection_filter(completion)[0] # if the prompt is None, break the loop if new_prompt is None: break # increment the current iteration current_iteration += 1 # call the completion handler with the new prompt kwargs["messages"] = messages kwargs["prompt"] = new_prompt model, messages, completion, usage = completion_handler(**kwargs) return (model, messages, completion, usage,) @litellm_base class LiteLLMCompletionProvider: # Define the input types for the node @classmethod def INPUT_TYPES(cls): # Inputs can be used to customize the default architecture return { "required": { "model": ("LITELLM_MODEL", {"default": "anthropic/claude-3-haiku-20240307"}), "max_tokens": ("INT", {"default": 250}), "temperature": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}), "top_p": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}), "frequency_penalty": ("FLOAT", {"default": 0}), "presence_penalty": ("FLOAT", {"default": 0}), "prompt": ("STRING", {"default": "Hello World!", "multiline": True}), }, "optional": { "messages": ("LLLM_MESSAGES", {"default": None}), } } RETURN_TYPES = ("CALLABLE",) RETURN_NAMES = ("Completion function",) # Method to provide the default LiteLLM model def handler(self, **kwargs): from copy import deepcopy litellm.drop_params = True model = kwargs.get('model', 'anthropic/claude-3-haiku-20240307') messages = kwargs.get('messages', []) messages = deepcopy(messages) max_tokens = kwargs.get('max_tokens', None) temperature = kwargs.get('temperature', None) top_p = kwargs.get('top_p', None) frequency_penalty = kwargs.get('frequency_penalty', None) presence_penalty = kwargs.get('presence_penalty', None) base_messages = messages.copy() # here we need to compose a callable that we will return def completion_function(prompt): messages = base_messages.copy() # append the prompt to the messages messages.append({"content": prompt, "role": "user"}) use_kwargs = { "model": model, "messages": messages, "stream": False, "max_tokens": max_tokens, "temperature": temperature, "top_p": top_p, "frequency_penalty": frequency_penalty, "presence_penalty": presence_penalty, } # Handle custom endpoint model configurations if isinstance(model, dict) and "kwargs" in model: # Extract and update with custom kwargs (api_key, api_base, etc.) use_kwargs.update(model["kwargs"]) # Fix model string when we have a dict with kwargs use_kwargs["model"] = model["model"] _apply_openai_max_token_overrides(use_kwargs) ret = litellm.completion(**use_kwargs) # now extract the actual content response_choices = ret.choices response_first_choice = response_choices[0] response_first_choice_message = response_first_choice.message response_first_choice_message_content = response_first_choice_message.content response_content = response_first_choice_message_content or "" return response_content return (completion_function,) @litellm_base class LiteLLMImageCaptioningProvider: @classmethod def INPUT_TYPES(cls): return { "required": { "model": ("LITELLM_MODEL", {"default": "anthropic/claude-3.5-sonnet"}), "max_tokens": ("INT", {"default": 250, "min": 1, "max": 1e10, "step": 1}), "temperature": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}), "top_p": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}), "frequency_penalty": ("FLOAT", {"default": 0}), "presence_penalty": ("FLOAT", {"default": 0}), }, "optional": { "image": ("IMAGE", {"default": None}), # Optional image input "prompt": ("STRING", {"default": "Describe the image:", "multiline": True}), "messages": ("LLLM_MESSAGES", {"default": None}), } } RETURN_TYPES = ("CALLABLE",) RETURN_NAMES = ("Captioning function",) def tensor_image_to_base64(self, tensor): """ Convert a tensor in the format (batch_size, height, width, channels) with float values [0, 1] to a base64-encoded JPEG image. """ from PIL import Image from io import BytesIO import base64 # Ensure the tensor is in the correct shape and format if tensor.dim() == 4: # Batch of images tensor = tensor[0] # Take the first image in the batch elif tensor.dim() != 3: raise ValueError(f"Expected tensor with 3 or 4 dimensions, got {tensor.dim()}") # Convert from float [0, 1] to uint8 [0, 255] tensor = tensor.mul(255).byte() # Convert to numpy array and then to PIL image image = Image.fromarray(tensor.numpy(), mode="RGB") # Encode to base64 buffer = BytesIO() image.save(buffer, format="JPEG", quality=100) return base64.b64encode(buffer.getvalue()).decode("utf-8") def get_image_data(self, base64_image): image_data = { "type": "image_url", "image_url": { "url": "data:image/jpeg;base64," + base64_image } } return image_data def handler(self, **kwargs): from copy import deepcopy model = kwargs.get('model', 'anthropic/claude-3.5-sonnet') messages = kwargs.get('messages', []) messages = deepcopy(messages) max_tokens = kwargs.get('max_tokens', None) temperature = kwargs.get('temperature', None) top_p = kwargs.get('top_p', None) frequency_penalty = kwargs.get('frequency_penalty', None) presence_penalty = kwargs.get('presence_penalty', None) image = kwargs.get('image', None) prompt = kwargs.get('prompt', "Describe the image:") # Encode image to base64 if provided base64_image = None if image is not None: base64_image = self.tensor_image_to_base64(image) # Append the initial prompt and image (if provided) to the messages base_messages = messages.copy() if base64_image: image_data = self.get_image_data(base64_image) base_messages.append({"role": "user", "content": [{"type": "text", "text": prompt}, image_data]}) else: base_messages.append({"role": "user", "content": prompt}) # Define the callable function for captioning def captioning_function(new_prompt=None, new_image=None): litellm.set_verbose = False litellm.suppress_debug_info = True messages = base_messages.copy() # Update the prompt if a new one is provided if new_prompt: if isinstance(messages[-1]["content"], list): # If the last message contains an image, update the text part messages[-1]["content"][0]["text"] = new_prompt else: # If it's a text-only message, update the content messages[-1]["content"] = new_prompt # Update the image if a new one is provided if new_image is not None: new_base64_image = self.tensor_image_to_base64(new_image) new_image_data = self.get_image_data(new_base64_image) if isinstance(messages[-1]["content"], list): # If the last message contains an image, replace it messages[-1]["content"][1] = new_image_data else: # If it's a text-only message, append the image messages[-1]["content"] = [{"type": "text", "text": new_prompt or prompt}, new_image_data] use_kwargs = { "model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature, "top_p": top_p, "frequency_penalty": frequency_penalty, "presence_penalty": presence_penalty, } _apply_openai_max_token_overrides(use_kwargs) # Call LiteLLM for captioning response = litellm.completion(**use_kwargs) # Extract the caption from the response response_choices = response.choices response_first_choice = response_choices[0] response_first_choice_message = response_first_choice.message caption = response_first_choice_message.content or "" return caption return (captioning_function,) @litellm_base class LiteLLMMessage: # Define the input types for the node @classmethod def INPUT_TYPES(cls): # Inputs can be used to customize the default architecture return { "required": { "content": ("STRING", {"default": "Hello World!", "multiline": True}), "role": (["user", "assistant", "system"], {"default": "user"}), }, "optional": { "messages": ("LLLM_MESSAGES", {"default": None}), } } # Define the return types RETURN_TYPES = ("LLLM_MESSAGES",) RETURN_NAMES = ("Message(s)",) # Method to provide the default LiteLLM model def handler(self, **kwargs): from copy import deepcopy existing_messages = kwargs.get('messages', []) existing_messages = deepcopy(existing_messages) content = kwargs.get('content', "") if content == "": return (existing_messages,) role = kwargs.get('role', "user") new_message = {"content": content, "role": role} new_messages = existing_messages + [new_message] return (new_messages,) @litellm_base class LLLMInterpretUrl: @classmethod def INPUT_TYPES(cls): ret = {"required": { "url": ("STRING", {"multiline": False, "default": "http://127.0.0.1:3389/infer"}), "get_what": (["text", "links", "urls", "article"],) }, "optional": { "headers": ("STRING", {"multiline": True, "default": "Content-Type:application/json"}), "url_list": ("LIST", {"default": []}) } } return ret RETURN_TYPES = ("STRING", "LIST",) # FUNCTION = "http_get" CATEGORY = "network" def handler(self, url, headers=None, get_what="text", url_list=[]): import asyncio from aiohttp import ClientSession from bs4 import BeautifulSoup import time dailymail = '
' async def fetch(url, session): try: async with session.get(url, timeout=10) as response: return await response.text() except Exception as e: return f"An error occurred: {e}" async def get_all_links(url, session): html = await fetch(url, session) soup = BeautifulSoup(html, 'html.parser') links = [f"(\"{a.string}\", \"{a['href']}\")" for a in soup.find_all('a', href=True)] return "[\n" + ",\n".join(links) + "\n]" async def get_all_visible_text(url, session): html = await fetch(url, session) soup = BeautifulSoup(html, 'html.parser') try: text = soup.body.get_text(separator=' ', strip=True) except AttributeError: text = soup.get_text(separator=' ', strip=True) # also try to get the article text from the daily mail if "dailymail.co.uk" in url: try: text = soup.find("div", {"itemprop": "articleBody"}).get_text(separator=' ', strip=True) except AttributeError: pass return text async def get_article_text(url, session): import article_parser html = await fetch(url, session) title, content = article_parser.parse(html=html, output="markdown") return f"{title}\n\n{content}" async def bounded_fetch(sem, url, session): async with sem: if get_what == "text": return await get_all_visible_text(url, session) elif get_what == "links": t = await get_all_links(url, session) return t elif get_what == "article": return await get_article_text(url, session) else: return "Invalid option" async def run(): tasks = [] sem = asyncio.Semaphore(5) async with ClientSession() as session: for url in url_list: task = asyncio.ensure_future(bounded_fetch(sem, url, session)) tasks.append(task) responses = await asyncio.gather(*tasks) return responses if url_list == []: url_list = [url] loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) ret = loop.run_until_complete(run()) loop.close() for _ in range(0, len(url_list), 5): time.sleep(10) # sleep for 10 seconds if len(ret) == 1: if get_what == "text": return (ret[0], ret,) elif get_what == "links": return (ret[0], eval(ret[0]),) return (ret[0], ret,) else: if get_what == "text": return ("\n".join(ret), ret,) elif get_what == "links": raise NotImplementedError return ("\n".join(ret), ret,) @litellm_base class ListToMessages: """simply convert any list of messages check the for the correct keys in each message in the list""" @classmethod def INPUT_TYPES(cls): return { "required": { "messages": ("LIST", {"default": []}), }, } RETURN_TYPES = ("LLLM_MESSAGES",) RETURN_NAMES = ("Messages",) def handler(self, messages): if not messages: return (messages,) for message in messages: if "content" not in message: raise ValueError("Each message in the list must have a 'content' key") if "role" not in message: raise ValueError("Each message in the list must have a 'role' key") return (messages,) @litellm_base class MessagesToList: """converts LLLM_MESSAGES to a list of messages""" @classmethod def INPUT_TYPES(cls): return { "required": { "messages": ("LLLM_MESSAGES", {"default": []}), }, } RETURN_TYPES = ("LIST",) RETURN_NAMES = ("Messages",) def handler(self, messages): if not messages: return (messages,) for message in messages: if "content" not in message: raise ValueError("Each message in the list must have a 'content' key") if "role" not in message: raise ValueError("Each message in the list must have a 'role' key") return (messages,) @litellm_base class AppendMessages: """ Appends messages to the existing messages """ @classmethod def INPUT_TYPES(cls): return { "required": { "existing_messages": ("LLLM_MESSAGES", {"default": []}), "new_messages": ("LLLM_MESSAGES", {"default": []}), }, } RETURN_TYPES = ("LLLM_MESSAGES",) RETURN_NAMES = ("Messages",) def handler(self, existing_messages, new_messages): return (existing_messages + new_messages,) @litellm_base class MessagesToText: """ Converts LLLM_MESSAGES to a single string """ @classmethod def INPUT_TYPES(cls): return { "required": { "messages": ("LLLM_MESSAGES", {"default": []}), }, } RETURN_TYPES = ("STRING",) RETURN_NAMES = ("Text",) def handler(self, messages): delimiter = "\uFF1A" # Fullwidth Colon (U+FF1A) # Check if any message content contains the unique Unicode delimiter for message in messages: if delimiter in message['content']: raise ValueError(f"Message content contains the delimiter {delimiter}") # Convert messages to a single string return ("\n".join([f"{m['role'].upper()}{delimiter} {m['content']}" for m in messages]),) def handler(self, messages): return ("\n".join([f"{m['role'].upper()}: {m['content']}" for m in messages]),) @litellm_base class TextToMessages: """ Converts a string to a list of messages """ @classmethod def INPUT_TYPES(cls): return { "required": { "text": ("STRING", {"default": ""}), }, } RETURN_TYPES = ("LLLM_MESSAGES",) RETURN_NAMES = ("Messages",) def handler(self, text): import re delimiter = "\uFF1A" # Fullwidth Colon (U+FF1A) pattern = re.compile(rf'^(user|assistant|system){delimiter}\s*(.*)$', re.IGNORECASE | re.MULTILINE) ret = [] for match in pattern.finditer(text): role, content = match.groups() role = role.strip().lower() content = content.strip() if not content: raise ValueError("Content cannot be empty") ret.append({"role": role, "content": content}) if not ret: raise ValueError("Invalid message format") return (ret,)