diff --git a/src/lfx/src/lfx/_assets/component_index.json b/src/lfx/src/lfx/_assets/component_index.json index 6c37b11ffe..4cf7cc5d1f 100644 --- a/src/lfx/src/lfx/_assets/component_index.json +++ b/src/lfx/src/lfx/_assets/component_index.json @@ -4249,7 +4249,7 @@ "icon": "Globe", "legacy": false, "metadata": { - "code_hash": "716a753c256e", + "code_hash": "71438e194131", "dependencies": { "dependencies": [ { @@ -4351,7 +4351,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import json\nimport re\nimport tempfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse\n\nimport aiofiles\nimport aiofiles.os as aiofiles_os\nimport httpx\nimport validators\n\nfrom lfx.base.curl.parse import parse_context\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import TabInput\nfrom lfx.io import (\n BoolInput,\n DataInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n Output,\n TableInput,\n)\nfrom lfx.schema.data import Data\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.utils.component_utils import set_current_fields, set_field_advanced, set_field_display\n\n# SSRF Protection imports - for preventing Server-Side Request Forgery attacks\nfrom lfx.utils.ssrf_protection import (\n SSRFProtectionError,\n is_ssrf_protection_enabled,\n validate_and_resolve_url,\n)\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Define fields for each mode\nMODE_FIELDS = {\n \"URL\": [\n \"url_input\",\n \"method\",\n ],\n \"cURL\": [\"curl_input\"],\n}\n\n# Fields that should always be visible\nDEFAULT_FIELDS = [\"mode\"]\n\n# HTTP redirect status codes (RFC 9110).\nHTTP_MOVED_PERMANENTLY = 301\nHTTP_FOUND = 302\nHTTP_SEE_OTHER = 303\nHTTP_TEMPORARY_REDIRECT = 307\nHTTP_PERMANENT_REDIRECT = 308\n\n# Maximum number of redirects to follow when re-validating each hop (matches httpx's default).\nMAX_REDIRECTS = 20\n\n# HTTP status codes that represent a redirect carrying a Location header.\nREDIRECT_STATUS_CODES = frozenset(\n {\n HTTP_MOVED_PERMANENTLY,\n HTTP_FOUND,\n HTTP_SEE_OTHER,\n HTTP_TEMPORARY_REDIRECT,\n HTTP_PERMANENT_REDIRECT,\n }\n)\n\n# Default ports per scheme, used to compare redirect origins.\nDEFAULT_SCHEME_PORTS = {\"http\": 80, \"https\": 443}\n\n\nclass APIRequestComponent(Component):\n display_name = \"API Request\"\n description = \"Make HTTP requests using URL or cURL commands.\"\n documentation: str = \"https://docs.langflow.org/api-request\"\n icon = \"Globe\"\n name = \"APIRequest\"\n\n inputs = [\n MessageTextInput(\n name=\"url_input\",\n display_name=\"URL\",\n info=\"Enter the URL for the request.\",\n advanced=False,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"curl_input\",\n display_name=\"cURL\",\n info=(\n \"Paste a curl command to populate the fields. \"\n \"This will fill in the dictionary fields for headers and body.\"\n ),\n real_time_refresh=True,\n tool_mode=True,\n advanced=True,\n show=False,\n ),\n DropdownInput(\n name=\"method\",\n display_name=\"Method\",\n options=[\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"],\n value=\"GET\",\n info=\"The HTTP method to use.\",\n real_time_refresh=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[\"URL\", \"cURL\"],\n value=\"URL\",\n info=\"Enable cURL mode to populate fields from a cURL command.\",\n real_time_refresh=True,\n ),\n DataInput(\n name=\"query_params\",\n display_name=\"Query Parameters\",\n info=\"The query parameters to append to the URL.\",\n advanced=True,\n ),\n TableInput(\n name=\"body\",\n display_name=\"Body\",\n info=\"The body to send with the request as a dictionary (for POST, PATCH, PUT).\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Key\",\n \"type\": \"str\",\n \"description\": \"Parameter name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"description\": \"Parameter value\",\n },\n ],\n value=[],\n input_types=[\"Data\", \"JSON\"],\n advanced=True,\n real_time_refresh=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": \"Langflow/1.0\"}],\n advanced=True,\n input_types=[\"Data\", \"JSON\"],\n real_time_refresh=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n value=30,\n info=\"The timeout to use for the request.\",\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n value=False,\n info=(\n \"Whether to follow HTTP redirects. \"\n \"WARNING: Enabling redirects may allow SSRF bypass attacks where a public URL \"\n \"redirects to internal resources. Only enable if you trust the target server. \"\n \"See OWASP SSRF Prevention Cheat Sheet for details.\"\n ),\n advanced=True,\n ),\n BoolInput(\n name=\"save_to_file\",\n display_name=\"Save to File\",\n value=False,\n info=\"Save the API response to a temporary file\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_httpx_metadata\",\n display_name=\"Include HTTPx Metadata\",\n value=False,\n info=(\n \"Include properties such as headers, status_code, response_headers, \"\n \"and redirection_history in the output.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"API Response\", name=\"data\", method=\"make_api_request\"),\n ]\n\n def _parse_json_value(self, value: Any) -> Any:\n \"\"\"Parse a value that might be a JSON string.\"\"\"\n if not isinstance(value, str):\n return value\n\n try:\n parsed = json.loads(value)\n except json.JSONDecodeError:\n return value\n else:\n return parsed\n\n def _process_body(self, body: Any) -> dict:\n \"\"\"Process the body input into a valid dictionary.\"\"\"\n if body is None:\n return {}\n if hasattr(body, \"data\"):\n body = body.data\n if isinstance(body, dict):\n return self._process_dict_body(body)\n if isinstance(body, str):\n return self._process_string_body(body)\n if isinstance(body, list):\n return self._process_list_body(body)\n return {}\n\n def _process_dict_body(self, body: dict) -> dict:\n \"\"\"Process dictionary body by parsing JSON values.\"\"\"\n return {k: self._parse_json_value(v) for k, v in body.items()}\n\n def _process_string_body(self, body: str) -> dict:\n \"\"\"Process string body by attempting JSON parse.\"\"\"\n try:\n return self._process_body(json.loads(body))\n except json.JSONDecodeError:\n return {\"data\": body}\n\n def _process_list_body(self, body: list) -> dict:\n \"\"\"Process list body by converting to key-value dictionary.\"\"\"\n processed_dict = {}\n try:\n for item in body:\n # Unwrap Data objects\n current_item = item\n if hasattr(item, \"data\"):\n unwrapped_data = item.data\n # If the unwrapped data is a dict but not key-value format, use it directly\n if isinstance(unwrapped_data, dict) and not self._is_valid_key_value_item(unwrapped_data):\n return unwrapped_data\n current_item = unwrapped_data\n if not self._is_valid_key_value_item(current_item):\n continue\n key = current_item[\"key\"]\n value = self._parse_json_value(current_item[\"value\"])\n processed_dict[key] = value\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process body list: {e}\")\n return {}\n return processed_dict\n\n def _is_valid_key_value_item(self, item: Any) -> bool:\n \"\"\"Check if an item is a valid key-value dictionary.\"\"\"\n return isinstance(item, dict) and \"key\" in item and \"value\" in item\n\n def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:\n \"\"\"Parse a cURL command and update build configuration.\"\"\"\n try:\n parsed = parse_context(curl)\n\n # Update basic configuration\n url = parsed.url\n # Normalize URL before setting it\n url = self._normalize_url(url)\n\n build_config[\"url_input\"][\"value\"] = url\n build_config[\"method\"][\"value\"] = parsed.method.upper()\n\n # Process headers\n headers_list = [{\"key\": k, \"value\": v} for k, v in parsed.headers.items()]\n build_config[\"headers\"][\"value\"] = headers_list\n\n # Process body data\n if not parsed.data:\n build_config[\"body\"][\"value\"] = []\n elif parsed.data:\n try:\n json_data = json.loads(parsed.data)\n if isinstance(json_data, dict):\n body_list = [\n {\"key\": k, \"value\": json.dumps(v) if isinstance(v, dict | list) else str(v)}\n for k, v in json_data.items()\n ]\n build_config[\"body\"][\"value\"] = body_list\n else:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": json.dumps(json_data)}]\n except json.JSONDecodeError:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": parsed.data}]\n\n except Exception as exc:\n msg = f\"Error parsing curl: {exc}\"\n self.log(msg)\n raise ValueError(msg) from exc\n\n return build_config\n\n def _normalize_url(self, url: str) -> str:\n \"\"\"Normalize URL by adding https:// if no protocol is specified.\"\"\"\n if not url or not isinstance(url, str):\n msg = \"URL cannot be empty\"\n raise ValueError(msg)\n\n url = url.strip()\n if url.startswith((\"http://\", \"https://\")):\n return url\n return f\"https://{url}\"\n\n async def make_request(\n self,\n client: httpx.AsyncClient,\n method: str,\n url: str,\n headers: dict | None = None,\n body: Any = None,\n timeout: int = 5,\n *,\n follow_redirects: bool = False,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n redirection_history = []\n\n try:\n # Prepare request parameters\n request_params = {\n \"method\": method,\n \"url\": url,\n \"headers\": headers,\n \"timeout\": timeout,\n \"follow_redirects\": follow_redirects,\n }\n # Only include body for methods that support it (GET must not have a body per HTTP spec)\n if method in {\"POST\", \"PATCH\", \"PUT\", \"DELETE\"} and processed_body is not None:\n request_params[\"json\"] = processed_body\n response = await client.request(**request_params)\n\n redirection_history = [\n {\n \"url\": redirect.headers.get(\"Location\", str(redirect.url)),\n \"status_code\": redirect.status_code,\n }\n for redirect in response.history\n ]\n\n return await self._build_response_data(\n response,\n url,\n headers,\n redirection_history,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n async def _build_response_data(\n self,\n response: httpx.Response,\n source_url: str,\n headers: dict | None,\n redirection_history: list,\n *,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n \"\"\"Turn an httpx response into the component's ``Data`` output.\n\n Shared by the standard request path (``make_request``) and the redirect\n re-validation path (``_follow_redirects_with_validation``) so both produce\n identical metadata, optional file saving, and body decoding.\n \"\"\"\n is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)\n response_headers = self._headers_to_dict(response.headers)\n\n # Base metadata\n metadata = {\n \"source\": source_url,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n }\n\n if redirection_history:\n metadata[\"redirection_history\"] = redirection_history\n\n if save_to_file:\n mode = \"wb\" if is_binary else \"w\"\n encoding = response.encoding if mode == \"w\" else None\n if file_path:\n await aiofiles_os.makedirs(file_path.parent, exist_ok=True)\n if is_binary:\n async with aiofiles.open(file_path, \"wb\") as f:\n await f.write(response.content)\n await f.flush()\n else:\n async with aiofiles.open(file_path, \"w\", encoding=encoding) as f:\n await f.write(response.text)\n await f.flush()\n metadata[\"file_path\"] = str(file_path)\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n return Data(data=metadata)\n\n # Handle response content\n if is_binary:\n result = response.content\n else:\n try:\n result = response.json()\n except json.JSONDecodeError:\n self.log(\"Failed to decode JSON response\")\n result = response.text.encode(\"utf-8\")\n\n metadata[\"result\"] = result\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n\n return Data(data=metadata)\n\n def add_query_params(self, url: str, params: dict) -> str:\n \"\"\"Add query parameters to URL efficiently.\"\"\"\n if not params:\n return url\n url_parts = list(urlparse(url))\n query = dict(parse_qsl(url_parts[4]))\n query.update(params)\n url_parts[4] = urlencode(query)\n return urlunparse(url_parts)\n\n def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:\n \"\"\"Convert HTTP headers to a dictionary with lowercased keys.\"\"\"\n return {k.lower(): v for k, v in headers.items()}\n\n def _process_headers(self, headers: Any) -> dict:\n \"\"\"Process the headers input into a valid dictionary.\"\"\"\n if headers is None:\n return {}\n if isinstance(headers, dict):\n return headers\n if isinstance(headers, list):\n return {item[\"key\"]: item[\"value\"] for item in headers if self._is_valid_key_value_item(item)}\n return {}\n\n async def make_api_request(self) -> Data:\n \"\"\"Make HTTP request with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. The protection works by:\n 1. Validating the URL and resolving DNS during security check\n 2. Pinning the validated IP address\n 3. Forcing the HTTP client to use the pinned IP for the actual request\n 4. Ignoring any subsequent DNS changes (prevents rebinding attacks)\n\n Returns:\n Data: Response data from the HTTP request\n\n Raises:\n ValueError: If URL is invalid or blocked by SSRF protection\n \"\"\"\n # Extract request parameters\n method = self.method\n url = self.url_input.strip() if isinstance(self.url_input, str) else \"\"\n headers = self.headers or {}\n body = self.body or {}\n timeout = self.timeout\n follow_redirects = self.follow_redirects\n save_to_file = self.save_to_file\n include_httpx_metadata = self.include_httpx_metadata\n\n # Security warning: HTTP redirects can bypass SSRF protection\n # A public URL could redirect to an internal resource\n if follow_redirects:\n self.log(\n \"Security Warning: HTTP redirects are enabled. This may allow SSRF bypass attacks \"\n \"where a public URL redirects to internal resources (e.g., cloud metadata endpoints). \"\n \"Only enable this if you trust the target server.\"\n )\n\n # Normalize URL (add https:// if no protocol specified)\n url = self._normalize_url(url)\n\n # Basic URL format validation\n if not validators.url(url):\n msg = f\"Invalid URL provided: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Pinning the validated IP address\n # 3. Using a custom HTTP transport that forces use of the pinned IP\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IP is pinned: \"example.com = 93.184.216.34\"\n # - HTTP request: uses pinned IP directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IP\n # ============================================================================\n\n try:\n # Validate URL and get validated IPs for DNS pinning\n _validated_url, validated_ips = validate_and_resolve_url(url)\n\n # Log DNS pinning information for security auditing\n if validated_ips:\n self.log(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s)\")\n\n except SSRFProtectionError as e:\n # SSRF protection blocked the request (private IP, internal network, etc.)\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n\n # Process query parameters (from string or Data object)\n if isinstance(self.query_params, str):\n query_params = dict(parse_qsl(self.query_params))\n else:\n query_params = self.query_params.data if self.query_params else {}\n\n # Process headers and body into proper format\n headers = self._process_headers(headers)\n body = self._process_body(body)\n url = self.add_query_params(url, query_params)\n\n # ============================================================================\n # Execute the request (re-validating any redirects when SSRF protection is on)\n # ============================================================================\n # When SSRF protection is enabled we must NOT let httpx auto-follow redirects:\n # a validated public URL can redirect to an internal address (loopback, RFC1918,\n # link-local / cloud metadata) that was never checked, bypassing both the initial\n # validation and DNS pinning. Instead we follow redirects manually so every hop\n # is re-validated with the same denylist + DNS pinning. When protection is\n # disabled, we preserve the previous behavior and let httpx handle redirects.\n if is_ssrf_protection_enabled() and follow_redirects:\n result = await self._follow_redirects_with_validation(\n method,\n url,\n headers,\n body,\n timeout,\n validated_ips,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n else:\n # No redirect re-validation needed:\n # - SSRF protection is disabled (user opted out), or\n # - redirects are disabled, so httpx makes a single request.\n # DNS pinning still applies to the single request when protection is enabled\n # and the host resolved to validated IPs.\n async with self._build_http_client(url, validated_ips) as client:\n result = await self.make_request(\n client,\n method,\n url,\n headers,\n body,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n\n self.status = result\n return result\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client, pinning DNS to validated IPs when SSRF protection applies.\n\n Args:\n url: The request URL whose hostname will be pinned.\n validated_ips: IPs validated by ``validate_and_resolve_url`` for this hop.\n\n Returns:\n httpx.AsyncClient: A client that pins DNS to ``validated_ips`` (preventing\n rebinding) when SSRF protection is enabled and the hop has validated IPs;\n otherwise a standard client (protection disabled, allowlisted host, or\n hostname extraction failure).\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n # Extract hostname from the URL so the custom transport can pin it while\n # preserving the Host header for virtual hosting / TLS SNI.\n hostname = urlparse(url).hostname\n if hostname:\n # The custom transport tries validated IPs in order (dual-stack / LB).\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n @staticmethod\n def _method_for_redirect(method: str, status_code: int) -> str:\n \"\"\"Return the HTTP method to use after a redirect, mirroring httpx semantics.\n\n A 303 (See Other) always becomes GET; 301/302 downgrade POST to GET for\n browser compatibility; 307/308 preserve the original method (and body).\n \"\"\"\n method = method.upper()\n if status_code == HTTP_SEE_OTHER and method != \"HEAD\":\n return \"GET\"\n if status_code in (HTTP_MOVED_PERMANENTLY, HTTP_FOUND) and method == \"POST\":\n return \"GET\"\n return method\n\n @staticmethod\n def _headers_for_redirect(headers: dict | None, current_url: str, next_url: str) -> dict | None:\n \"\"\"Drop sensitive headers when a redirect crosses to a different origin.\n\n Mirrors httpx's auto-follow behavior so manually following redirects does not\n leak credentials (Authorization / Cookie) to an origin other than the one the\n caller intended them for. Headers are kept only when the redirect stays on the\n same origin (scheme, host, port) or is a direct https upgrade of the same host\n on default ports - the exact cases where httpx keeps the Authorization header.\n \"\"\"\n if not headers:\n return headers\n current, nxt = urlparse(current_url), urlparse(next_url)\n current_port = current.port or DEFAULT_SCHEME_PORTS.get(current.scheme)\n next_port = nxt.port or DEFAULT_SCHEME_PORTS.get(nxt.scheme)\n same_origin = (current.scheme, current.hostname, current_port) == (nxt.scheme, nxt.hostname, next_port)\n https_upgrade = (\n current.hostname == nxt.hostname\n and current.scheme == \"http\"\n and nxt.scheme == \"https\"\n and current_port == DEFAULT_SCHEME_PORTS[\"http\"]\n and next_port == DEFAULT_SCHEME_PORTS[\"https\"]\n )\n if same_origin or https_upgrade:\n return headers\n sensitive = {\"authorization\", \"proxy-authorization\", \"cookie\"}\n return {k: v for k, v in headers.items() if k.lower() not in sensitive}\n\n async def _follow_redirects_with_validation(\n self,\n method: str,\n url: str,\n headers: dict | None,\n body: Any,\n timeout: int,\n validated_ips: list[str],\n *,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n \"\"\"Make the request and follow redirects manually, re-validating every hop.\n\n This closes an SSRF bypass: with ``follow_redirects`` enabled, httpx would\n otherwise auto-follow a redirect from a validated public URL to an internal\n address that was never checked. Here each redirect ``Location`` is resolved\n (relative locations included) and re-validated with ``validate_and_resolve_url``\n — the same private/loopback/link-local denylist and DNS pinning applied to the\n initial request — before any connection to it is made. A blocked hop raises\n ``ValueError``; the number of redirects is capped at ``MAX_REDIRECTS``.\n \"\"\"\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n current_url = url\n current_ips = validated_ips\n redirection_history: list[dict] = []\n\n for _ in range(MAX_REDIRECTS + 1):\n request_params: dict[str, Any] = {\n \"method\": method,\n \"url\": current_url,\n \"headers\": headers,\n \"timeout\": timeout,\n # Never let httpx follow redirects itself; each hop is validated below.\n \"follow_redirects\": False,\n }\n # Only include body for methods that support it (GET must not have a body).\n if method in {\"POST\", \"PATCH\", \"PUT\", \"DELETE\"} and processed_body is not None:\n request_params[\"json\"] = processed_body\n\n try:\n async with self._build_http_client(current_url, current_ips) as client:\n response = await client.request(**request_params)\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {current_url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n location = response.headers.get(\"Location\")\n if response.status_code in REDIRECT_STATUS_CODES and location:\n # Resolve relative redirects against the current URL.\n next_url = urljoin(current_url, location)\n redirection_history.append({\"url\": location, \"status_code\": response.status_code})\n\n # Re-validate the redirect target with the same SSRF denylist + DNS pinning.\n # Non-http(s) schemes, private/loopback/link-local hosts, and hostnames that\n # resolve to blocked IPs all raise SSRFProtectionError here.\n try:\n _validated_url, current_ips = validate_and_resolve_url(next_url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: blocked redirect to {next_url}: {e}\"\n raise ValueError(msg) from e\n\n method = self._method_for_redirect(method, response.status_code)\n headers = self._headers_for_redirect(headers, current_url, next_url)\n current_url = next_url\n continue\n\n # Not a redirect (or no Location header) - this is the final response.\n return await self._build_response_data(\n response,\n url,\n headers,\n redirection_history,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n\n msg = f\"SSRF Protection: exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\"\n raise ValueError(msg)\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n \"\"\"Update the build config based on the selected mode.\"\"\"\n if field_name != \"mode\":\n if field_name == \"curl_input\" and self.mode == \"cURL\" and self.curl_input:\n return self.parse_curl(self.curl_input, build_config)\n return build_config\n\n if field_value == \"cURL\":\n set_field_display(build_config, \"curl_input\", value=True)\n if build_config[\"curl_input\"][\"value\"]:\n try:\n build_config = self.parse_curl(build_config[\"curl_input\"][\"value\"], build_config)\n except ValueError as e:\n self.log(f\"Failed to parse cURL input: {e}\")\n else:\n set_field_display(build_config, \"curl_input\", value=False)\n\n return set_current_fields(\n build_config=build_config,\n action_fields=MODE_FIELDS,\n selected_action=field_value,\n default_fields=DEFAULT_FIELDS,\n func=set_field_advanced,\n default_value=True,\n )\n\n async def _response_info(\n self, response: httpx.Response, *, with_file_path: bool = False\n ) -> tuple[bool, Path | None]:\n \"\"\"Determine the file path and whether the response content is binary.\n\n Args:\n response (Response): The HTTP response object.\n with_file_path (bool): Whether to save the response content to a file.\n\n Returns:\n Tuple[bool, Path | None]:\n A tuple containing a boolean indicating if the content is binary and the full file path (if applicable).\n \"\"\"\n content_type = response.headers.get(\"Content-Type\", \"\")\n is_binary = \"application/octet-stream\" in content_type or \"application/binary\" in content_type\n\n if not with_file_path:\n return is_binary, None\n\n component_temp_dir = Path(tempfile.gettempdir()) / self.__class__.__name__\n\n # Create directory asynchronously\n await aiofiles_os.makedirs(component_temp_dir, exist_ok=True)\n\n filename = None\n if \"Content-Disposition\" in response.headers:\n content_disposition = response.headers[\"Content-Disposition\"]\n filename_match = re.search(r'filename=\"(.+?)\"', content_disposition)\n if filename_match:\n extracted_filename = filename_match.group(1)\n filename = extracted_filename\n\n # Step 3: Infer file extension or use part of the request URL if no filename\n if not filename:\n # Extract the last segment of the URL path\n url_path = urlparse(str(response.request.url) if response.request else \"\").path\n base_name = Path(url_path).name # Get the last segment of the path\n if not base_name: # If the path ends with a slash or is empty\n base_name = \"response\"\n\n # Infer file extension\n content_type_to_extension = {\n \"text/plain\": \".txt\",\n \"application/json\": \".json\",\n \"image/jpeg\": \".jpg\",\n \"image/png\": \".png\",\n \"application/octet-stream\": \".bin\",\n }\n extension = content_type_to_extension.get(content_type, \".bin\" if is_binary else \".txt\")\n filename = f\"{base_name}{extension}\"\n\n # Step 4: Define the full file path\n file_path = component_temp_dir / filename\n\n # Step 5: Check if file exists asynchronously and handle accordingly\n try:\n # Try to create the file exclusively (x mode) to check existence\n async with aiofiles.open(file_path, \"x\") as _:\n pass # File created successfully, we can use this path\n except FileExistsError:\n # If file exists, append a timestamp to the filename\n timestamp = datetime.now(timezone.utc).strftime(\"%Y%m%d%H%M%S%f\")\n file_path = component_temp_dir / f\"{timestamp}-{filename}\"\n\n return is_binary, file_path\n" + "value": "import json\nimport re\nimport tempfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Any\nfrom urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse\n\nimport aiofiles\nimport aiofiles.os as aiofiles_os\nimport httpx\nimport validators\n\nfrom lfx.base.curl.parse import parse_context\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import TabInput\nfrom lfx.io import (\n BoolInput,\n DataInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n Output,\n TableInput,\n)\nfrom lfx.schema.data import Data\nfrom lfx.schema.dotdict import dotdict\nfrom lfx.utils.component_utils import set_current_fields, set_field_advanced, set_field_display\n\n# SSRF Protection imports - for preventing Server-Side Request Forgery attacks\nfrom lfx.utils.ssrf_protection import (\n SSRFProtectionError,\n is_ssrf_protection_enabled,\n validate_and_resolve_url,\n)\nfrom lfx.utils.ssrf_transport import create_ssrf_protected_client\n\n# Define fields for each mode\nMODE_FIELDS = {\n \"URL\": [\n \"url_input\",\n \"method\",\n ],\n \"cURL\": [\"curl_input\"],\n}\n\n# Fields that should always be visible\nDEFAULT_FIELDS = [\"mode\"]\n\n# HTTP redirect status codes (RFC 9110).\nHTTP_MOVED_PERMANENTLY = 301\nHTTP_FOUND = 302\nHTTP_SEE_OTHER = 303\nHTTP_TEMPORARY_REDIRECT = 307\nHTTP_PERMANENT_REDIRECT = 308\n\n# Maximum number of redirects to follow when re-validating each hop (matches httpx's default).\nMAX_REDIRECTS = 20\n\n# HTTP status codes that represent a redirect carrying a Location header.\nREDIRECT_STATUS_CODES = frozenset(\n {\n HTTP_MOVED_PERMANENTLY,\n HTTP_FOUND,\n HTTP_SEE_OTHER,\n HTTP_TEMPORARY_REDIRECT,\n HTTP_PERMANENT_REDIRECT,\n }\n)\n\n# Default ports per scheme, used to compare redirect origins.\nDEFAULT_SCHEME_PORTS = {\"http\": 80, \"https\": 443}\n\n\nclass APIRequestComponent(Component):\n display_name = \"API Request\"\n description = \"Make HTTP requests using URL or cURL commands.\"\n documentation: str = \"https://docs.langflow.org/api-request\"\n icon = \"Globe\"\n name = \"APIRequest\"\n\n inputs = [\n MessageTextInput(\n name=\"url_input\",\n display_name=\"URL\",\n info=\"Enter the URL for the request.\",\n advanced=False,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"curl_input\",\n display_name=\"cURL\",\n info=(\n \"Paste a curl command to populate the fields. \"\n \"This will fill in the dictionary fields for headers and body.\"\n ),\n real_time_refresh=True,\n tool_mode=True,\n advanced=True,\n show=False,\n ),\n DropdownInput(\n name=\"method\",\n display_name=\"Method\",\n options=[\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"],\n value=\"GET\",\n info=\"The HTTP method to use.\",\n real_time_refresh=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Mode\",\n options=[\"URL\", \"cURL\"],\n value=\"URL\",\n info=\"Enable cURL mode to populate fields from a cURL command.\",\n real_time_refresh=True,\n ),\n DataInput(\n name=\"query_params\",\n display_name=\"Query Parameters\",\n info=\"The query parameters to append to the URL.\",\n advanced=True,\n ),\n TableInput(\n name=\"body\",\n display_name=\"Body\",\n info=\"The body to send with the request as a dictionary (for POST, PATCH, PUT).\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Key\",\n \"type\": \"str\",\n \"description\": \"Parameter name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"description\": \"Parameter value\",\n },\n ],\n value=[],\n input_types=[\"Data\", \"JSON\"],\n advanced=True,\n real_time_refresh=True,\n ),\n TableInput(\n name=\"headers\",\n display_name=\"Headers\",\n info=\"The headers to send with the request\",\n table_schema=[\n {\n \"name\": \"key\",\n \"display_name\": \"Header\",\n \"type\": \"str\",\n \"description\": \"Header name\",\n },\n {\n \"name\": \"value\",\n \"display_name\": \"Value\",\n \"type\": \"str\",\n \"description\": \"Header value\",\n },\n ],\n value=[{\"key\": \"User-Agent\", \"value\": \"Langflow/1.0\"}],\n advanced=True,\n input_types=[\"Data\", \"JSON\"],\n real_time_refresh=True,\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout\",\n value=30,\n info=\"The timeout to use for the request.\",\n advanced=True,\n ),\n BoolInput(\n name=\"follow_redirects\",\n display_name=\"Follow Redirects\",\n value=False,\n info=(\n \"Whether to follow HTTP redirects. \"\n \"WARNING: Enabling redirects may allow SSRF bypass attacks where a public URL \"\n \"redirects to internal resources. Only enable if you trust the target server. \"\n \"See OWASP SSRF Prevention Cheat Sheet for details.\"\n ),\n advanced=True,\n ),\n BoolInput(\n name=\"save_to_file\",\n display_name=\"Save to File\",\n value=False,\n info=\"Save the API response to a temporary file\",\n advanced=True,\n ),\n BoolInput(\n name=\"include_httpx_metadata\",\n display_name=\"Include HTTPx Metadata\",\n value=False,\n info=(\n \"Include properties such as headers, status_code, response_headers, \"\n \"and redirection_history in the output.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"API Response\", name=\"data\", method=\"make_api_request\"),\n ]\n\n def _parse_json_value(self, value: Any) -> Any:\n \"\"\"Parse a value that might be a JSON string.\"\"\"\n if not isinstance(value, str):\n return value\n\n try:\n parsed = json.loads(value)\n except json.JSONDecodeError:\n return value\n else:\n return parsed\n\n def _process_body(self, body: Any) -> dict:\n \"\"\"Process the body input into a valid dictionary.\"\"\"\n if body is None:\n return {}\n if hasattr(body, \"data\"):\n body = body.data\n if isinstance(body, dict):\n return self._process_dict_body(body)\n if isinstance(body, str):\n return self._process_string_body(body)\n if isinstance(body, list):\n return self._process_list_body(body)\n return {}\n\n def _process_dict_body(self, body: dict) -> dict:\n \"\"\"Process dictionary body by parsing JSON values.\"\"\"\n return {k: self._parse_json_value(v) for k, v in body.items()}\n\n def _process_string_body(self, body: str) -> dict:\n \"\"\"Process string body by attempting JSON parse.\"\"\"\n try:\n return self._process_body(json.loads(body))\n except json.JSONDecodeError:\n return {\"data\": body}\n\n def _process_list_body(self, body: list) -> dict:\n \"\"\"Process list body by converting to key-value dictionary.\"\"\"\n processed_dict = {}\n try:\n for item in body:\n # Unwrap Data objects\n current_item = item\n if hasattr(item, \"data\"):\n unwrapped_data = item.data\n # If the unwrapped data is a dict but not key-value format, use it directly\n if isinstance(unwrapped_data, dict) and not self._is_valid_key_value_item(unwrapped_data):\n return unwrapped_data\n current_item = unwrapped_data\n if not self._is_valid_key_value_item(current_item):\n continue\n key = current_item[\"key\"]\n value = self._parse_json_value(current_item[\"value\"])\n processed_dict[key] = value\n except (KeyError, TypeError, ValueError) as e:\n self.log(f\"Failed to process body list: {e}\")\n return {}\n return processed_dict\n\n def _is_valid_key_value_item(self, item: Any) -> bool:\n \"\"\"Check if an item is a valid key-value dictionary.\"\"\"\n return isinstance(item, dict) and \"key\" in item and \"value\" in item\n\n def parse_curl(self, curl: str, build_config: dotdict) -> dotdict:\n \"\"\"Parse a cURL command and update build configuration.\"\"\"\n try:\n parsed = parse_context(curl)\n\n # Update basic configuration\n url = parsed.url\n # Normalize URL before setting it\n url = self._normalize_url(url)\n\n build_config[\"url_input\"][\"value\"] = url\n build_config[\"method\"][\"value\"] = parsed.method.upper()\n\n # Process headers\n headers_list = [{\"key\": k, \"value\": v} for k, v in parsed.headers.items()]\n build_config[\"headers\"][\"value\"] = headers_list\n\n # Process body data\n if not parsed.data:\n build_config[\"body\"][\"value\"] = []\n elif parsed.data:\n try:\n json_data = json.loads(parsed.data)\n if isinstance(json_data, dict):\n body_list = [\n {\"key\": k, \"value\": json.dumps(v) if isinstance(v, dict | list) else str(v)}\n for k, v in json_data.items()\n ]\n build_config[\"body\"][\"value\"] = body_list\n else:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": json.dumps(json_data)}]\n except json.JSONDecodeError:\n build_config[\"body\"][\"value\"] = [{\"key\": \"data\", \"value\": parsed.data}]\n\n except Exception as exc:\n msg = f\"Error parsing curl: {exc}\"\n self.log(msg)\n raise ValueError(msg) from exc\n\n return build_config\n\n def _normalize_url(self, url: str) -> str:\n \"\"\"Normalize URL by adding https:// if no protocol is specified.\"\"\"\n if not url or not isinstance(url, str):\n msg = \"URL cannot be empty\"\n raise ValueError(msg)\n\n url = url.strip()\n if url.startswith((\"http://\", \"https://\")):\n return url\n return f\"https://{url}\"\n\n async def make_request(\n self,\n client: httpx.AsyncClient,\n method: str,\n url: str,\n headers: dict | None = None,\n body: Any = None,\n timeout: int = 5,\n *,\n follow_redirects: bool = False,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n redirection_history = []\n\n try:\n # Prepare request parameters\n request_params = {\n \"method\": method,\n \"url\": url,\n \"headers\": headers,\n \"timeout\": timeout,\n \"follow_redirects\": follow_redirects,\n }\n # Only include body for methods that support it (GET must not have a body per HTTP spec)\n if method in {\"POST\", \"PATCH\", \"PUT\", \"DELETE\"} and processed_body is not None:\n request_params[\"json\"] = processed_body\n response = await client.request(**request_params)\n\n redirection_history = [\n {\n \"url\": redirect.headers.get(\"Location\", str(redirect.url)),\n \"status_code\": redirect.status_code,\n }\n for redirect in response.history\n ]\n\n return await self._build_response_data(\n response,\n url,\n headers,\n redirection_history,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n async def _build_response_data(\n self,\n response: httpx.Response,\n source_url: str,\n headers: dict | None,\n redirection_history: list,\n *,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n \"\"\"Turn an httpx response into the component's ``Data`` output.\n\n Shared by the standard request path (``make_request``) and the redirect\n re-validation path (``_follow_redirects_with_validation``) so both produce\n identical metadata, optional file saving, and body decoding.\n \"\"\"\n is_binary, file_path = await self._response_info(response, with_file_path=save_to_file)\n response_headers = self._headers_to_dict(response.headers)\n\n # Base metadata\n metadata = {\n \"source\": source_url,\n \"status_code\": response.status_code,\n \"response_headers\": response_headers,\n }\n\n if redirection_history:\n metadata[\"redirection_history\"] = redirection_history\n\n if save_to_file:\n mode = \"wb\" if is_binary else \"w\"\n encoding = response.encoding if mode == \"w\" else None\n if file_path:\n await aiofiles_os.makedirs(file_path.parent, exist_ok=True)\n if is_binary:\n async with aiofiles.open(file_path, \"wb\") as f:\n await f.write(response.content)\n await f.flush()\n else:\n async with aiofiles.open(file_path, \"w\", encoding=encoding) as f:\n await f.write(response.text)\n await f.flush()\n metadata[\"file_path\"] = str(file_path)\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n return Data(data=metadata)\n\n # Handle response content\n if is_binary:\n result = response.content\n else:\n try:\n result = response.json()\n except json.JSONDecodeError:\n self.log(\"Failed to decode JSON response\")\n result = response.text.encode(\"utf-8\")\n\n metadata[\"result\"] = result\n\n if include_httpx_metadata:\n metadata.update({\"headers\": headers})\n\n return Data(data=metadata)\n\n def add_query_params(self, url: str, params: dict) -> str:\n \"\"\"Add query parameters to URL efficiently.\"\"\"\n if not params:\n return url\n url_parts = list(urlparse(url))\n query = dict(parse_qsl(url_parts[4]))\n query.update(params)\n url_parts[4] = urlencode(query)\n return urlunparse(url_parts)\n\n def _headers_to_dict(self, headers: httpx.Headers) -> dict[str, str]:\n \"\"\"Convert HTTP headers to a dictionary with lowercased keys.\"\"\"\n return {k.lower(): v for k, v in headers.items()}\n\n def _process_headers(self, headers: Any) -> dict:\n \"\"\"Process the headers input into a valid dictionary.\"\"\"\n if headers is None:\n return {}\n if isinstance(headers, dict):\n return headers\n if isinstance(headers, list):\n return {item[\"key\"]: item[\"value\"] for item in headers if self._is_valid_key_value_item(item)}\n return {}\n\n async def make_api_request(self) -> Data:\n \"\"\"Make HTTP request with SSRF protection and DNS pinning.\n\n This method implements comprehensive SSRF (Server-Side Request Forgery) protection\n using DNS pinning to prevent DNS rebinding attacks. The protection works by:\n 1. Validating the URL and resolving DNS during security check\n 2. Pinning the validated IP address\n 3. Forcing the HTTP client to use the pinned IP for the actual request\n 4. Ignoring any subsequent DNS changes (prevents rebinding attacks)\n\n Returns:\n Data: Response data from the HTTP request\n\n Raises:\n ValueError: If URL is invalid or blocked by SSRF protection\n \"\"\"\n # Extract request parameters\n method = self.method\n url = self.url_input.strip() if isinstance(self.url_input, str) else \"\"\n headers = self.headers or {}\n body = self.body or {}\n timeout = self.timeout\n follow_redirects = self.follow_redirects\n save_to_file = self.save_to_file\n include_httpx_metadata = self.include_httpx_metadata\n\n # Security warning: HTTP redirects can bypass SSRF protection\n # A public URL could redirect to an internal resource\n if follow_redirects:\n self.log(\n \"Security Warning: HTTP redirects are enabled. This may allow SSRF bypass attacks \"\n \"where a public URL redirects to internal resources (e.g., cloud metadata endpoints). \"\n \"Only enable this if you trust the target server.\"\n )\n\n # Normalize URL (add https:// if no protocol specified)\n url = self._normalize_url(url)\n\n # Basic URL format validation\n if not validators.url(url):\n msg = f\"Invalid URL provided: {url}\"\n raise ValueError(msg)\n\n # ============================================================================\n # SSRF Protection with DNS Pinning\n # ============================================================================\n # This prevents DNS rebinding attacks by:\n # 1. Resolving DNS and validating IPs during security check\n # 2. Pinning the validated IP address\n # 3. Using a custom HTTP transport that forces use of the pinned IP\n # 4. Ignoring any new DNS resolutions (prevents rebinding)\n #\n # Without DNS pinning, an attacker could:\n # - First DNS lookup: returns public IP (passes validation)\n # - Second DNS lookup: returns internal IP (bypasses protection)\n # - Attack succeeds: accesses internal services\n #\n # With DNS pinning:\n # - First DNS lookup: returns public IP (passes validation)\n # - IP is pinned: \"example.com = 93.184.216.34\"\n # - HTTP request: uses pinned IP directly (no new DNS lookup)\n # - Attack fails: even if DNS changes, we use the validated IP\n # ============================================================================\n\n try:\n # Validate URL and get validated IPs for DNS pinning\n _validated_url, validated_ips = validate_and_resolve_url(url)\n\n # Log DNS pinning information for security auditing\n if validated_ips:\n self.log(f\"SSRF Protection: Using DNS pinning with {len(validated_ips)} validated IP(s)\")\n\n except SSRFProtectionError as e:\n # SSRF protection blocked the request (private IP, internal network, etc.)\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n\n # Process query parameters (from string or Data object)\n if isinstance(self.query_params, str):\n query_params = dict(parse_qsl(self.query_params))\n else:\n query_params = self.query_params.data if self.query_params else {}\n\n # Process headers and body into proper format\n headers = self._process_headers(headers)\n body = self._process_body(body)\n url = self.add_query_params(url, query_params)\n\n # ============================================================================\n # Execute the request (re-validating any redirects when SSRF protection is on)\n # ============================================================================\n # When SSRF protection is enabled we must NOT let httpx auto-follow redirects:\n # a validated public URL can redirect to an internal address (loopback, RFC1918,\n # link-local / cloud metadata) that was never checked, bypassing both the initial\n # validation and DNS pinning. Instead we follow redirects manually so every hop\n # is re-validated with the same denylist + DNS pinning. When protection is\n # disabled, we preserve the previous behavior and let httpx handle redirects.\n if is_ssrf_protection_enabled() and follow_redirects:\n result = await self._follow_redirects_with_validation(\n method,\n url,\n headers,\n body,\n timeout,\n validated_ips,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n else:\n # No redirect re-validation needed:\n # - SSRF protection is disabled (user opted out), or\n # - redirects are disabled, so httpx makes a single request.\n # DNS pinning still applies to the single request when protection is enabled\n # and the host resolved to validated IPs.\n async with self._build_http_client(url, validated_ips) as client:\n result = await self.make_request(\n client,\n method,\n url,\n headers,\n body,\n timeout,\n follow_redirects=follow_redirects,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n\n self.status = result\n return result\n\n def _build_http_client(self, url: str, validated_ips: list[str]) -> httpx.AsyncClient:\n \"\"\"Create an HTTP client, pinning DNS to validated IPs when SSRF protection applies.\n\n Args:\n url: The request URL whose hostname will be pinned.\n validated_ips: IPs validated by ``validate_and_resolve_url`` for this hop.\n\n Returns:\n httpx.AsyncClient: A client that pins DNS to ``validated_ips`` (preventing\n rebinding) when SSRF protection is enabled and the hop has validated IPs;\n otherwise a standard client (protection disabled, allowlisted host, or\n hostname extraction failure).\n \"\"\"\n if is_ssrf_protection_enabled() and validated_ips:\n # Extract hostname from the URL so the custom transport can pin it while\n # preserving the Host header for virtual hosting / TLS SNI.\n hostname = urlparse(url).hostname\n if hostname:\n # The custom transport tries validated IPs in order (dual-stack / LB).\n return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)\n return httpx.AsyncClient()\n\n @staticmethod\n def _method_for_redirect(method: str, status_code: int) -> str:\n \"\"\"Return the HTTP method to use after a redirect, mirroring httpx semantics.\n\n A 303 (See Other) always becomes GET; 301/302 downgrade POST to GET for\n browser compatibility; 307/308 preserve the original method (and body).\n \"\"\"\n method = method.upper()\n if status_code == HTTP_SEE_OTHER and method != \"HEAD\":\n return \"GET\"\n if status_code in (HTTP_MOVED_PERMANENTLY, HTTP_FOUND) and method == \"POST\":\n return \"GET\"\n return method\n\n @staticmethod\n def _headers_for_redirect(headers: dict | None, current_url: str, next_url: str) -> dict | None:\n \"\"\"Drop sensitive headers when a redirect crosses to a different origin.\n\n Mirrors httpx's auto-follow behavior so manually following redirects does not\n leak credentials (Authorization / Cookie) to an origin other than the one the\n caller intended them for. Headers are kept only when the redirect stays on the\n same origin (scheme, host, port) or is a direct https upgrade of the same host\n on default ports - the exact cases where httpx keeps the Authorization header.\n \"\"\"\n if not headers:\n return headers\n current, nxt = urlparse(current_url), urlparse(next_url)\n current_port = current.port or DEFAULT_SCHEME_PORTS.get(current.scheme)\n next_port = nxt.port or DEFAULT_SCHEME_PORTS.get(nxt.scheme)\n same_origin = (current.scheme, current.hostname, current_port) == (nxt.scheme, nxt.hostname, next_port)\n https_upgrade = (\n current.hostname == nxt.hostname\n and current.scheme == \"http\"\n and nxt.scheme == \"https\"\n and current_port == DEFAULT_SCHEME_PORTS[\"http\"]\n and next_port == DEFAULT_SCHEME_PORTS[\"https\"]\n )\n if same_origin or https_upgrade:\n return headers\n sensitive = {\"authorization\", \"proxy-authorization\", \"cookie\"}\n return {k: v for k, v in headers.items() if k.lower() not in sensitive}\n\n async def _follow_redirects_with_validation(\n self,\n method: str,\n url: str,\n headers: dict | None,\n body: Any,\n timeout: int,\n validated_ips: list[str],\n *,\n save_to_file: bool = False,\n include_httpx_metadata: bool = False,\n ) -> Data:\n \"\"\"Make the request and follow redirects manually, re-validating every hop.\n\n This closes an SSRF bypass: with ``follow_redirects`` enabled, httpx would\n otherwise auto-follow a redirect from a validated public URL to an internal\n address that was never checked. Here each redirect ``Location`` is resolved\n (relative locations included) and re-validated with ``validate_and_resolve_url``\n — the same private/loopback/link-local denylist and DNS pinning applied to the\n initial request — before any connection to it is made. A blocked hop raises\n ``ValueError``; the number of redirects is capped at ``MAX_REDIRECTS``.\n \"\"\"\n method = method.upper()\n if method not in {\"GET\", \"POST\", \"PATCH\", \"PUT\", \"DELETE\"}:\n msg = f\"Unsupported method: {method}\"\n raise ValueError(msg)\n\n processed_body = self._process_body(body)\n current_url = url\n current_ips = validated_ips\n redirection_history: list[dict] = []\n\n for _ in range(MAX_REDIRECTS + 1):\n request_params: dict[str, Any] = {\n \"method\": method,\n \"url\": current_url,\n \"headers\": headers,\n \"timeout\": timeout,\n # Never let httpx follow redirects itself; each hop is validated below.\n \"follow_redirects\": False,\n }\n # Only include body for methods that support it (GET must not have a body).\n if method in {\"POST\", \"PATCH\", \"PUT\", \"DELETE\"} and processed_body is not None:\n request_params[\"json\"] = processed_body\n\n try:\n async with self._build_http_client(current_url, current_ips) as client:\n response = await client.request(**request_params)\n except (httpx.HTTPError, httpx.RequestError, httpx.TimeoutException) as exc:\n self.log(f\"Error making request to {current_url}\")\n return Data(\n data={\n \"source\": url,\n \"headers\": headers,\n \"status_code\": 500,\n \"error\": str(exc),\n **({\"redirection_history\": redirection_history} if redirection_history else {}),\n },\n )\n\n location = response.headers.get(\"Location\")\n if response.status_code in REDIRECT_STATUS_CODES and location:\n # Resolve relative redirects against the current URL.\n next_url = urljoin(current_url, location)\n redirection_history.append({\"url\": location, \"status_code\": response.status_code})\n\n # Re-validate the redirect target with the same SSRF denylist + DNS pinning.\n # Non-http(s) schemes, private/loopback/link-local hosts, and hostnames that\n # resolve to blocked IPs all raise SSRFProtectionError here.\n try:\n _validated_url, current_ips = validate_and_resolve_url(next_url)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: blocked redirect to {next_url}: {e}\"\n raise ValueError(msg) from e\n\n method = self._method_for_redirect(method, response.status_code)\n headers = self._headers_for_redirect(headers, current_url, next_url)\n current_url = next_url\n continue\n\n # Not a redirect (or no Location header) - this is the final response.\n return await self._build_response_data(\n response,\n url,\n headers,\n redirection_history,\n save_to_file=save_to_file,\n include_httpx_metadata=include_httpx_metadata,\n )\n\n msg = f\"SSRF Protection: exceeded the maximum of {MAX_REDIRECTS} redirects while requesting {url}\"\n raise ValueError(msg)\n\n def update_build_config(self, build_config: dotdict, field_value: Any, field_name: str | None = None) -> dotdict:\n \"\"\"Update the build config based on the selected mode.\"\"\"\n if field_name != \"mode\":\n if field_name == \"curl_input\" and self.mode == \"cURL\" and self.curl_input:\n return self.parse_curl(self.curl_input, build_config)\n return build_config\n\n if field_value == \"cURL\":\n set_field_display(build_config, \"curl_input\", value=True)\n if build_config[\"curl_input\"][\"value\"]:\n try:\n build_config = self.parse_curl(build_config[\"curl_input\"][\"value\"], build_config)\n except ValueError as e:\n self.log(f\"Failed to parse cURL input: {e}\")\n else:\n set_field_display(build_config, \"curl_input\", value=False)\n\n return set_current_fields(\n build_config=build_config,\n action_fields=MODE_FIELDS,\n selected_action=field_value,\n default_fields=DEFAULT_FIELDS,\n func=set_field_advanced,\n default_value=True,\n )\n\n async def _response_info(\n self, response: httpx.Response, *, with_file_path: bool = False\n ) -> tuple[bool, Path | None]:\n \"\"\"Determine the file path and whether the response content is binary.\n\n Args:\n response (Response): The HTTP response object.\n with_file_path (bool): Whether to save the response content to a file.\n\n Returns:\n Tuple[bool, Path | None]:\n A tuple containing a boolean indicating if the content is binary and the full file path (if applicable).\n \"\"\"\n content_type = response.headers.get(\"Content-Type\", \"\")\n is_binary = \"application/octet-stream\" in content_type or \"application/binary\" in content_type\n\n if not with_file_path:\n return is_binary, None\n\n component_temp_dir = Path(tempfile.gettempdir()) / self.__class__.__name__\n\n # Create directory asynchronously\n await aiofiles_os.makedirs(component_temp_dir, exist_ok=True)\n\n filename = None\n if \"Content-Disposition\" in response.headers:\n content_disposition = response.headers[\"Content-Disposition\"]\n filename_match = re.search(r'filename=\"(.+?)\"', content_disposition)\n if filename_match:\n # Reduce to the basename to prevent path traversal: the response\n # (and therefore this header) is fully attacker-influenced, so a\n # value like filename=\"../../../../tmp/evil.sh\" must not escape\n # component_temp_dir. Path(...).name strips any directory parts.\n # Normalize backslashes to forward slashes first so Windows-style\n # separators (e.g. filename=\"..\\..\\tmp\\evil.sh\") are also stripped\n # on POSIX, where \"\\\\\" is a valid filename character that\n # Path(...).name would otherwise leave intact. NUL bytes are\n # stripped too: they survive Path(...).name and would otherwise\n # make the later .resolve() raise a cryptic \"embedded null\n # character\" ValueError instead of failing cleanly here.\n normalized_filename = filename_match.group(1).replace(\"\\\\\", \"/\").replace(\"\\x00\", \"\")\n extracted_filename = Path(normalized_filename).name\n if extracted_filename and extracted_filename not in (\".\", \"..\"):\n filename = extracted_filename\n\n # Step 3: Infer file extension or use part of the request URL if no filename\n if not filename:\n # Extract the last segment of the URL path\n url_path = urlparse(str(response.request.url) if response.request else \"\").path\n base_name = Path(url_path).name # Get the last segment of the path\n if not base_name: # If the path ends with a slash or is empty\n base_name = \"response\"\n\n # Infer file extension\n content_type_to_extension = {\n \"text/plain\": \".txt\",\n \"application/json\": \".json\",\n \"image/jpeg\": \".jpg\",\n \"image/png\": \".png\",\n \"application/octet-stream\": \".bin\",\n }\n extension = content_type_to_extension.get(content_type, \".bin\" if is_binary else \".txt\")\n filename = f\"{base_name}{extension}\"\n\n # Step 4: Define the full file path\n file_path = component_temp_dir / filename\n\n # Defense-in-depth: ensure the resolved path stays within the component\n # temp dir even if filename derivation above is ever changed.\n if not file_path.resolve().is_relative_to(component_temp_dir.resolve()):\n msg = \"Resolved output path escapes the component temporary directory\"\n raise ValueError(msg)\n\n # Step 5: Check if file exists asynchronously and handle accordingly\n try:\n # Try to create the file exclusively (x mode) to check existence\n async with aiofiles.open(file_path, \"x\") as _:\n pass # File created successfully, we can use this path\n except FileExistsError:\n # If file exists, append a timestamp to the filename\n timestamp = datetime.now(timezone.utc).strftime(\"%Y%m%d%H%M%S%f\")\n file_path = component_temp_dir / f\"{timestamp}-{filename}\"\n\n return is_binary, file_path\n" }, "curl_input": { "_input_type": "MultilineInput", @@ -5466,7 +5466,7 @@ "icon": "database", "legacy": false, "metadata": { - "code_hash": "a8dd79af50b8", + "code_hash": "fb4189cb5af3", "dependencies": { "dependencies": [ { @@ -5550,7 +5550,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from typing import TYPE_CHECKING, Any\n\nfrom langchain_community.utilities import SQLDatabase\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lfx.custom.custom_component.component_with_cache import ComponentWithCache\nfrom lfx.io import BoolInput, MessageTextInput, MultilineInput, Output\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.services.cache.utils import CacheMiss\n\nif TYPE_CHECKING:\n from sqlalchemy.engine import Result\n\n\nclass SQLComponent(ComponentWithCache):\n \"\"\"A sql component.\"\"\"\n\n display_name = \"SQL Database\"\n description = \"Executes SQL queries on SQLAlchemy-compatible databases.\"\n documentation: str = \"https://docs.langflow.org/sql-database\"\n icon = \"database\"\n name = \"SQLComponent\"\n metadata = {\"keywords\": [\"sql\", \"database\", \"query\", \"db\", \"fetch\"]}\n\n def __init__(self, **kwargs) -> None:\n super().__init__(**kwargs)\n self.db: SQLDatabase = None\n\n def maybe_create_db(self):\n if self.database_url != \"\":\n if self._shared_component_cache:\n cached_db = self._shared_component_cache.get(self.database_url)\n if not isinstance(cached_db, CacheMiss):\n self.db = cached_db\n return\n self.log(\"Connecting to database\")\n try:\n self.db = SQLDatabase.from_uri(self.database_url)\n except Exception as e:\n msg = f\"An error occurred while connecting to the database: {e}\"\n raise ValueError(msg) from e\n if self._shared_component_cache:\n self._shared_component_cache.set(self.database_url, self.db)\n\n inputs = [\n MessageTextInput(name=\"database_url\", display_name=\"Database URL\", required=True),\n MultilineInput(name=\"query\", display_name=\"SQL Query\", tool_mode=True, required=True),\n BoolInput(name=\"include_columns\", display_name=\"Include Columns\", value=True, tool_mode=True, advanced=True),\n BoolInput(\n name=\"add_error\",\n display_name=\"Add Error\",\n value=False,\n tool_mode=True,\n info=\"If True, the error will be added to the result\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Result Table\", name=\"run_sql_query\", method=\"run_sql_query\"),\n ]\n\n def build_component(\n self,\n ) -> Message:\n error = None\n self.maybe_create_db()\n try:\n result = self.db.run(self.query, include_columns=self.include_columns)\n self.status = result\n except SQLAlchemyError as e:\n msg = f\"An error occurred while running the SQL Query: {e}\"\n self.log(msg)\n result = str(e)\n self.status = result\n error = repr(e)\n\n if self.add_error and error is not None:\n result = f\"{result}\\n\\nError: {error}\\n\\nQuery: {self.query}\"\n elif error is not None:\n # Then we won't add the error to the result\n result = self.query\n\n return Message(text=result)\n\n def __execute_query(self) -> list[dict[str, Any]]:\n self.maybe_create_db()\n try:\n cursor: Result[Any] = self.db.run(self.query, fetch=\"cursor\")\n return [x._asdict() for x in cursor.fetchall()]\n except SQLAlchemyError as e:\n msg = f\"An error occurred while running the SQL Query: {e}\"\n self.log(msg)\n raise ValueError(msg) from e\n\n def run_sql_query(self) -> DataFrame:\n result = self.__execute_query()\n df_result = DataFrame(result)\n self.status = df_result\n return df_result\n" + "value": "from typing import TYPE_CHECKING, Any\n\nfrom langchain_community.utilities import SQLDatabase\nfrom sqlalchemy.exc import SQLAlchemyError\n\nfrom lfx.custom.custom_component.component_with_cache import ComponentWithCache\nfrom lfx.io import BoolInput, MessageTextInput, MultilineInput, Output\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.services.cache.utils import CacheMiss\n\nif TYPE_CHECKING:\n from sqlalchemy.engine import Result\n\nSQL_DATABASE_ENGINE_ARGS = {\"pool_pre_ping\": True}\n\n\nclass SQLComponent(ComponentWithCache):\n \"\"\"A sql component.\"\"\"\n\n display_name = \"SQL Database\"\n description = \"Executes SQL queries on SQLAlchemy-compatible databases.\"\n documentation: str = \"https://docs.langflow.org/sql-database\"\n icon = \"database\"\n name = \"SQLComponent\"\n metadata = {\"keywords\": [\"sql\", \"database\", \"query\", \"db\", \"fetch\"]}\n\n def __init__(self, **kwargs) -> None:\n super().__init__(**kwargs)\n self.db: SQLDatabase = None\n\n def maybe_create_db(self):\n if self.database_url != \"\":\n if self._shared_component_cache:\n cached_db = self._shared_component_cache.get(self.database_url)\n if not isinstance(cached_db, CacheMiss):\n self.db = cached_db\n return\n self.log(\"Connecting to database\")\n try:\n self.db = SQLDatabase.from_uri(self.database_url, engine_args=SQL_DATABASE_ENGINE_ARGS)\n except Exception as e:\n msg = f\"An error occurred while connecting to the database: {e}\"\n raise ValueError(msg) from e\n if self._shared_component_cache:\n self._shared_component_cache.set(self.database_url, self.db)\n\n inputs = [\n MessageTextInput(name=\"database_url\", display_name=\"Database URL\", required=True),\n MultilineInput(name=\"query\", display_name=\"SQL Query\", tool_mode=True, required=True),\n BoolInput(name=\"include_columns\", display_name=\"Include Columns\", value=True, tool_mode=True, advanced=True),\n BoolInput(\n name=\"add_error\",\n display_name=\"Add Error\",\n value=False,\n tool_mode=True,\n info=\"If True, the error will be added to the result\",\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Result Table\", name=\"run_sql_query\", method=\"run_sql_query\"),\n ]\n\n def build_component(\n self,\n ) -> Message:\n error = None\n self.maybe_create_db()\n try:\n result = self.db.run(self.query, include_columns=self.include_columns)\n self.status = result\n except SQLAlchemyError as e:\n msg = f\"An error occurred while running the SQL Query: {e}\"\n self.log(msg)\n result = str(e)\n self.status = result\n error = repr(e)\n\n if self.add_error and error is not None:\n result = f\"{result}\\n\\nError: {error}\\n\\nQuery: {self.query}\"\n elif error is not None:\n # Then we won't add the error to the result\n result = self.query\n\n return Message(text=result)\n\n def __execute_query(self) -> list[dict[str, Any]]:\n self.maybe_create_db()\n try:\n cursor: Result[Any] = self.db.run(self.query, fetch=\"cursor\")\n return [x._asdict() for x in cursor.fetchall()]\n except SQLAlchemyError as e:\n msg = f\"An error occurred while running the SQL Query: {e}\"\n self.log(msg)\n raise ValueError(msg) from e\n\n def run_sql_query(self) -> DataFrame:\n result = self.__execute_query()\n df_result = DataFrame(result)\n self.status = df_result\n return df_result\n" }, "database_url": { "_input_type": "MessageTextInput", @@ -7731,7 +7731,7 @@ }, { "name": "cryptography", - "version": "48.0.0" + "version": "48.0.1" }, { "name": "langchain_chroma", @@ -11168,6 +11168,8 @@ }, "LoopComponent": { "base_classes": [ + "Data", + "DataFrame", "JSON", "Table" ], @@ -11185,7 +11187,7 @@ "icon": "infinity", "legacy": false, "metadata": { - "code_hash": "0b08e58a5c70", + "code_hash": "2712a1de7210", "dependencies": { "dependencies": [ { @@ -11210,9 +11212,10 @@ ], "method": "item_output", "name": "item", - "selected": "JSON", + "selected": "Data", "tool_mode": true, "types": [ + "Data", "JSON" ], "value": "__UNDEFINED__" @@ -11224,9 +11227,10 @@ "group_outputs": true, "method": "done_output", "name": "done", - "selected": "Table", + "selected": "DataFrame", "tool_mode": true, "types": [ + "DataFrame", "Table" ], "value": "__UNDEFINED__" @@ -11251,17 +11255,19 @@ "show": true, "title_case": false, "type": "code", - "value": "from lfx.base.flow_controls.loop_utils import (\n execute_loop_body,\n extract_loop_output,\n get_loop_body_start_edge,\n get_loop_body_start_vertex,\n get_loop_body_vertices,\n validate_data_input,\n)\nfrom lfx.components.processing.converter import convert_to_data\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import HandleInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.template.field.base import Output\n\n\nclass LoopComponent(Component):\n display_name = \"Loop\"\n description = (\n \"Iterates through Data or Message objects, processing items individually \"\n \"and aggregating results from loop inputs.\"\n )\n documentation: str = \"https://docs.langflow.org/loop\"\n icon = \"infinity\"\n\n inputs = [\n HandleInput(\n name=\"data\",\n display_name=\"Inputs\",\n info=\"The initial DataFrame to iterate over.\",\n input_types=[\"DataFrame\", \"Table\"],\n ),\n ]\n\n outputs = [\n Output(\n display_name=\"Item\",\n name=\"item\",\n method=\"item_output\",\n allows_loop=True,\n loop_types=[\"Message\"],\n group_outputs=True,\n ),\n Output(display_name=\"Done\", name=\"done\", method=\"done_output\", group_outputs=True),\n ]\n\n def initialize_data(self) -> None:\n \"\"\"Initialize the data list and context index.\n\n Seeds the input list and index counter in ctx. The aggregated results\n are owned by `_iterate`, which writes them once the subgraph finishes.\n \"\"\"\n if self.ctx.get(f\"{self._id}_initialized\", False):\n return\n\n # Ensure data is a list of Data objects\n data_list = self._validate_data(self.data)\n\n # Store the initial data and context variables\n self.update_ctx(\n {\n f\"{self._id}_data\": data_list,\n f\"{self._id}_index\": 0,\n f\"{self._id}_initialized\": True,\n }\n )\n\n def _convert_message_to_data(self, message: Message) -> Data:\n \"\"\"Convert a Message object to a Data object using Type Convert logic.\"\"\"\n return convert_to_data(message, auto_parse=False)\n\n def _validate_data(self, data):\n \"\"\"Validate and return a list of Data objects.\"\"\"\n return validate_data_input(data)\n\n def get_loop_body_vertices(self) -> set[str]:\n \"\"\"Identify vertices in this loop's body via graph traversal.\n\n Traverses from the loop's \"item\" output to the vertex that feeds back\n to the loop's \"item\" input, collecting all vertices in between.\n This naturally handles nested loops by stopping at this loop's feedback edge.\n\n Returns:\n Set of vertex IDs that form this loop's body\n \"\"\"\n # Check if we have a proper graph context\n if not hasattr(self, \"_vertex\") or self._vertex is None:\n return set()\n\n return get_loop_body_vertices(\n vertex=self._vertex,\n graph=self.graph,\n get_incoming_edge_by_target_param_fn=self.get_incoming_edge_by_target_param,\n )\n\n def _get_loop_body_start_vertex(self) -> str | None:\n \"\"\"Get the first vertex in the loop body (connected to loop's item output).\n\n Returns:\n The vertex ID of the first vertex in the loop body, or None if not found\n \"\"\"\n # Check if we have a proper graph context\n if not hasattr(self, \"_vertex\") or self._vertex is None:\n return None\n\n return get_loop_body_start_vertex(vertex=self._vertex)\n\n def _extract_loop_output(self, results: list) -> Data:\n \"\"\"Extract the output from subgraph execution results.\n\n Args:\n results: List of VertexBuildResult objects from subgraph execution\n\n Returns:\n Data object containing the loop iteration output\n \"\"\"\n # Get the vertex ID that feeds back to the item input (end of loop body)\n end_vertex_id = self.get_incoming_edge_by_target_param(\"item\")\n return extract_loop_output(results=results, end_vertex_id=end_vertex_id)\n\n async def execute_loop_body(self, data_list: list[Data], event_manager=None) -> list[Data]:\n \"\"\"Execute loop body for each data item.\n\n Creates an isolated subgraph for the loop body and executes it\n for each item in the data list, collecting results.\n\n Args:\n data_list: List of Data objects to iterate over\n event_manager: Optional event manager to pass to subgraph execution for UI events\n\n Returns:\n List of Data objects containing results from each iteration\n \"\"\"\n # Get the loop body configuration once\n loop_body_vertex_ids = self.get_loop_body_vertices()\n start_vertex_id = self._get_loop_body_start_vertex()\n start_edge = get_loop_body_start_edge(self._vertex)\n end_vertex_id = self.get_incoming_edge_by_target_param(\"item\")\n\n return await execute_loop_body(\n graph=self.graph,\n data_list=data_list,\n loop_body_vertex_ids=loop_body_vertex_ids,\n start_vertex_id=start_vertex_id,\n start_edge=start_edge,\n end_vertex_id=end_vertex_id,\n event_manager=event_manager,\n )\n\n async def _iterate(self) -> list[Data]:\n \"\"\"Run the loop body subgraph once and cache the aggregated results.\n\n Both `item_output` and `done_output` may be called during the same\n vertex build (if both outputs have downstream consumers), so this\n helper is idempotent: it caches the aggregated results in ctx under\n `{self._id}_aggregated` and guards re-entry with `{self._id}_iterated`.\n If a prior call raised, the exception is cached in\n `{self._id}_iteration_error` and re-raised on subsequent calls so\n repeat invocations surface the same failure instead of silently\n returning empty data or re-running the subgraph.\n\n Production constructs a fresh Graph per request (via\n `Graph.from_payload`), so `ctx` is effectively per-run and the\n cached `_iterated` flag does not leak across executions.\n \"\"\"\n if self.ctx.get(f\"{self._id}_iterated\", False):\n cached_error = self.ctx.get(f\"{self._id}_iteration_error\")\n if cached_error is not None:\n raise cached_error\n return self.ctx.get(f\"{self._id}_aggregated\", [])\n\n import time\n\n started_at = time.perf_counter()\n try:\n self.initialize_data()\n data_list = self.ctx.get(f\"{self._id}_data\", [])\n self.log(f\"Starting loop over {len(data_list)} item(s)\", name=\"Start\")\n\n if not data_list:\n self.update_ctx({f\"{self._id}_aggregated\": [], f\"{self._id}_iterated\": True})\n self.log(\"No items to iterate, skipping loop body\", name=\"Skipped\")\n return []\n\n aggregated_results = await self.execute_loop_body(data_list, event_manager=self._event_manager)\n except Exception as exc:\n from lfx.log.logger import logger\n\n elapsed = time.perf_counter() - started_at\n self.log(f\"Loop failed after {elapsed:.3f}s: {exc}\", name=\"Error\")\n await logger.aexception(f\"Loop {self._id} failed while executing loop body\")\n self.update_ctx({f\"{self._id}_iteration_error\": exc, f\"{self._id}_iterated\": True})\n raise\n\n elapsed = time.perf_counter() - started_at\n self.log(\n f\"Completed {len(aggregated_results)} iteration(s) in {elapsed:.3f}s\",\n name=\"Complete\",\n )\n self.update_ctx({f\"{self._id}_aggregated\": aggregated_results, f\"{self._id}_iterated\": True})\n return aggregated_results\n\n async def item_output(self) -> Data:\n \"\"\"Display the inputs dispatched to the loop body.\n\n Also triggers the iteration (idempotent) when `done` has no\n downstream consumer, so the loop still runs if only the Item\n output is connected. When `done` IS connected we leave iteration\n to `done_output`. The `item` branch is stopped in both cases so\n downstream vertices aren't executed by the outer graph (the loop\n body runs internally via the subgraph in `_iterate`).\n\n Returns a `Data` envelope so the outer item edge remains\n compatible with Data-typed consumers in the loop body. The\n wrapped payload exposes the iterated rows for inspection.\n \"\"\"\n self.stop(\"item\")\n if self._vertex is not None and \"done\" not in self._vertex.edges_source_names:\n await self._iterate()\n data_list = self.ctx.get(f\"{self._id}_data\", [])\n return Data(data={\"count\": len(data_list), \"items\": [d.data for d in data_list]})\n\n async def done_output(self) -> DataFrame:\n \"\"\"Return the aggregated results from the loop iteration.\n\n Triggers the iteration if it hasn't run yet (for example when only\n `done` is connected and `item_output` never executed). In the common\n case where `item` is also connected, `_iterate` has already cached\n the aggregated results in ctx and this call is a cheap read, so the\n order in which the two outputs are evaluated does not matter.\n \"\"\"\n aggregated_results = await self._iterate()\n return DataFrame(aggregated_results)\n" + "value": "from lfx.base.flow_controls.loop_utils import (\n execute_loop_body,\n extract_loop_output,\n get_loop_body_start_edge,\n get_loop_body_start_vertex,\n get_loop_body_vertices,\n validate_data_input,\n)\nfrom lfx.components.processing.converter import convert_to_data\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import HandleInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.template.field.base import Output\n\n\nclass LoopComponent(Component):\n display_name = \"Loop\"\n description = (\n \"Iterates through Data or Message objects, processing items individually \"\n \"and aggregating results from loop inputs.\"\n )\n documentation: str = \"https://docs.langflow.org/loop\"\n icon = \"infinity\"\n\n inputs = [\n HandleInput(\n name=\"data\",\n display_name=\"Inputs\",\n info=\"Data to iterate over. Accepts DataFrame, Table, Data, or Message.\",\n input_types=[\"DataFrame\", \"Table\", \"Data\", \"Message\"],\n ),\n ]\n\n outputs = [\n Output(\n display_name=\"Item\",\n name=\"item\",\n method=\"item_output\",\n types=[\"Data\"],\n allows_loop=True,\n loop_types=[\"Message\"],\n group_outputs=True,\n ),\n Output(\n display_name=\"Done\",\n name=\"done\",\n method=\"done_output\",\n types=[\"DataFrame\", \"Table\"],\n group_outputs=True,\n ),\n ]\n\n def initialize_data(self) -> None:\n \"\"\"Initialize the data list and context index.\n\n Seeds the input list and index counter in ctx. The aggregated results\n are owned by `_iterate`, which writes them once the subgraph finishes.\n \"\"\"\n if self.ctx.get(f\"{self._id}_initialized\", False):\n return\n\n # Ensure data is a list of Data objects\n data_list = self._validate_data(self.data)\n\n # Store the initial data and context variables\n self.update_ctx(\n {\n f\"{self._id}_data\": data_list,\n f\"{self._id}_index\": 0,\n f\"{self._id}_initialized\": True,\n }\n )\n\n def _convert_message_to_data(self, message: Message) -> Data:\n \"\"\"Convert a Message object to a Data object using Type Convert logic.\"\"\"\n return convert_to_data(message, auto_parse=False)\n\n def _validate_data(self, data):\n \"\"\"Validate and normalize the input to a flat list of Data objects.\n\n Coerces each accepted input type into Data so `validate_data_input`\n always receives a homogeneous payload:\n - Message -> a clean Data via `_convert_message_to_data` (just the\n message payload, not the full envelope). `Message` subclasses\n `Data`, so without this it would slip through as a single Data\n carrying all of the message metadata.\n - DataFrame/Table -> expanded to its rows.\n - Data / list[Data] -> passed through unchanged.\n\n Lists are normalized item-by-item so a mixed `[Message, DataFrame,\n Data]` still yields `list[Data]` instead of being rejected as a\n mixed-type list (`validate_data_input` requires every list item to be\n a Data, and DataFrame is not a Data subclass).\n \"\"\"\n if isinstance(data, Message):\n data = self._convert_message_to_data(data)\n elif isinstance(data, list):\n normalized: list = []\n for item in data:\n if isinstance(item, Message):\n normalized.append(self._convert_message_to_data(item))\n elif isinstance(item, DataFrame):\n normalized.extend(item.to_data_list())\n else:\n normalized.append(item)\n data = normalized\n return validate_data_input(data)\n\n def get_loop_body_vertices(self) -> set[str]:\n \"\"\"Identify vertices in this loop's body via graph traversal.\n\n Traverses from the loop's \"item\" output to the vertex that feeds back\n to the loop's \"item\" input, collecting all vertices in between.\n This naturally handles nested loops by stopping at this loop's feedback edge.\n\n Returns:\n Set of vertex IDs that form this loop's body\n \"\"\"\n # Check if we have a proper graph context\n if not hasattr(self, \"_vertex\") or self._vertex is None:\n return set()\n\n return get_loop_body_vertices(\n vertex=self._vertex,\n graph=self.graph,\n get_incoming_edge_by_target_param_fn=self.get_incoming_edge_by_target_param,\n )\n\n def _get_loop_body_start_vertex(self) -> str | None:\n \"\"\"Get the first vertex in the loop body (connected to loop's item output).\n\n Returns:\n The vertex ID of the first vertex in the loop body, or None if not found\n \"\"\"\n # Check if we have a proper graph context\n if not hasattr(self, \"_vertex\") or self._vertex is None:\n return None\n\n return get_loop_body_start_vertex(vertex=self._vertex)\n\n def _extract_loop_output(self, results: list) -> Data:\n \"\"\"Extract the output from subgraph execution results.\n\n Args:\n results: List of VertexBuildResult objects from subgraph execution\n\n Returns:\n Data object containing the loop iteration output\n \"\"\"\n # Get the vertex ID that feeds back to the item input (end of loop body)\n end_vertex_id = self.get_incoming_edge_by_target_param(\"item\")\n return extract_loop_output(results=results, end_vertex_id=end_vertex_id)\n\n async def execute_loop_body(self, data_list: list[Data], event_manager=None) -> list[Data]:\n \"\"\"Execute loop body for each data item.\n\n Creates an isolated subgraph for the loop body and executes it\n for each item in the data list, collecting results.\n\n Args:\n data_list: List of Data objects to iterate over\n event_manager: Optional event manager to pass to subgraph execution for UI events\n\n Returns:\n List of Data objects containing results from each iteration\n \"\"\"\n # Get the loop body configuration once\n loop_body_vertex_ids = self.get_loop_body_vertices()\n start_vertex_id = self._get_loop_body_start_vertex()\n start_edge = get_loop_body_start_edge(self._vertex)\n end_vertex_id = self.get_incoming_edge_by_target_param(\"item\")\n\n return await execute_loop_body(\n graph=self.graph,\n data_list=data_list,\n loop_body_vertex_ids=loop_body_vertex_ids,\n start_vertex_id=start_vertex_id,\n start_edge=start_edge,\n end_vertex_id=end_vertex_id,\n event_manager=event_manager,\n )\n\n async def _iterate(self) -> list[Data]:\n \"\"\"Run the loop body subgraph once and cache the aggregated results.\n\n Both `item_output` and `done_output` may be called during the same\n vertex build (if both outputs have downstream consumers), so this\n helper is idempotent: it caches the aggregated results in ctx under\n `{self._id}_aggregated` and guards re-entry with `{self._id}_iterated`.\n If a prior call raised, the exception is cached in\n `{self._id}_iteration_error` and re-raised on subsequent calls so\n repeat invocations surface the same failure instead of silently\n returning empty data or re-running the subgraph.\n\n Production constructs a fresh Graph per request (via\n `Graph.from_payload`), so `ctx` is effectively per-run and the\n cached `_iterated` flag does not leak across executions.\n \"\"\"\n if self.ctx.get(f\"{self._id}_iterated\", False):\n cached_error = self.ctx.get(f\"{self._id}_iteration_error\")\n if cached_error is not None:\n raise cached_error\n return self.ctx.get(f\"{self._id}_aggregated\", [])\n\n import time\n\n started_at = time.perf_counter()\n try:\n self.initialize_data()\n data_list = self.ctx.get(f\"{self._id}_data\", [])\n self.log(f\"Starting loop over {len(data_list)} item(s)\", name=\"Start\")\n\n if not data_list:\n self.update_ctx({f\"{self._id}_aggregated\": [], f\"{self._id}_iterated\": True})\n self.log(\"No items to iterate, skipping loop body\", name=\"Skipped\")\n return []\n\n aggregated_results = await self.execute_loop_body(data_list, event_manager=self._event_manager)\n except Exception as exc:\n from lfx.log.logger import logger\n\n elapsed = time.perf_counter() - started_at\n self.log(f\"Loop failed after {elapsed:.3f}s: {exc}\", name=\"Error\")\n await logger.aexception(f\"Loop {self._id} failed while executing loop body\")\n self.update_ctx({f\"{self._id}_iteration_error\": exc, f\"{self._id}_iterated\": True})\n raise\n\n elapsed = time.perf_counter() - started_at\n self.log(\n f\"Completed {len(aggregated_results)} iteration(s) in {elapsed:.3f}s\",\n name=\"Complete\",\n )\n self.update_ctx({f\"{self._id}_aggregated\": aggregated_results, f\"{self._id}_iterated\": True})\n return aggregated_results\n\n async def item_output(self) -> Data:\n \"\"\"Display the inputs dispatched to the loop body.\n\n Also triggers the iteration (idempotent) when `done` has no\n downstream consumer, so the loop still runs if only the Item\n output is connected. When `done` IS connected we leave iteration\n to `done_output`. The `item` branch is stopped in both cases so\n downstream vertices aren't executed by the outer graph (the loop\n body runs internally via the subgraph in `_iterate`).\n\n Returns a `Data` envelope so the outer item edge remains\n compatible with Data-typed consumers in the loop body. The\n wrapped payload exposes the iterated rows for inspection.\n \"\"\"\n self.stop(\"item\")\n if self._vertex is not None and \"done\" not in self._vertex.edges_source_names:\n await self._iterate()\n data_list = self.ctx.get(f\"{self._id}_data\", [])\n return Data(data={\"count\": len(data_list), \"items\": [d.data for d in data_list]})\n\n async def done_output(self) -> DataFrame:\n \"\"\"Return the aggregated results from the loop iteration.\n\n Triggers the iteration if it hasn't run yet (for example when only\n `done` is connected and `item_output` never executed). In the common\n case where `item` is also connected, `_iterate` has already cached\n the aggregated results in ctx and this call is a cheap read, so the\n order in which the two outputs are evaluated does not matter.\n \"\"\"\n aggregated_results = await self._iterate()\n return DataFrame(aggregated_results)\n" }, "data": { "_input_type": "HandleInput", "advanced": false, "display_name": "Inputs", "dynamic": false, - "info": "The initial DataFrame to iterate over.", + "info": "Data to iterate over. Accepts DataFrame, Table, Data, or Message.", "input_types": [ "DataFrame", - "Table" + "Table", + "Data", + "Message" ], "list": false, "list_add_label": "Add More", @@ -12776,7 +12782,7 @@ "icon": "LangChain", "legacy": false, "metadata": { - "code_hash": "550088b86234", + "code_hash": "1ef20e6ad613", "dependencies": { "dependencies": [ { @@ -12944,7 +12950,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import contextlib\nimport tempfile\nfrom pathlib import Path\n\nfrom lfx.base.agents.agent import LCAgentComponent\nfrom lfx.base.data.storage_utils import read_file_bytes\nfrom lfx.base.models.unified_models import get_language_model_options, get_llm, handle_model_input_update\nfrom lfx.base.models.watsonx_constants import IBM_WATSONX_URLS\nfrom lfx.field_typing import AgentExecutor\nfrom lfx.inputs.inputs import (\n DictInput,\n DropdownInput,\n FileInput,\n MessageTextInput,\n ModelInput,\n)\nfrom lfx.io import BoolInput, SecretStrInput, StrInput\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.template.field.base import Output\nfrom lfx.utils.async_helpers import run_until_complete\n\n\nclass CSVAgentComponent(LCAgentComponent):\n display_name = \"CSV Agent\"\n description = \"Construct a CSV agent from a CSV and tools.\"\n documentation = \"https://python.langchain.com/docs/modules/agents/toolkits/csv\"\n name = \"CSVAgent\"\n icon = \"LangChain\"\n\n inputs = [\n *LCAgentComponent.get_base_inputs(),\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=\"Select your model provider or connect a Language Model component.\",\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Overrides global provider settings. Leave blank to use your pre-configured API Key.\",\n real_time_refresh=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"base_url_ibm_watsonx\",\n display_name=\"watsonx API Endpoint\",\n info=\"The base URL of the API (IBM watsonx.ai only)\",\n options=IBM_WATSONX_URLS,\n value=IBM_WATSONX_URLS[0],\n show=False,\n real_time_refresh=True,\n ),\n StrInput(\n name=\"project_id\",\n display_name=\"watsonx Project ID\",\n info=\"The project ID associated with the foundation model (IBM watsonx.ai only)\",\n show=False,\n required=False,\n ),\n FileInput(\n name=\"path\",\n display_name=\"File Path\",\n file_types=[\"csv\"],\n input_types=[\"str\", \"Message\"],\n required=True,\n info=\"A CSV File or File Path.\",\n ),\n DropdownInput(\n name=\"agent_type\",\n display_name=\"Agent Type\",\n advanced=True,\n options=[\"zero-shot-react-description\", \"openai-functions\", \"openai-tools\"],\n value=\"openai-tools\",\n ),\n MessageTextInput(\n name=\"input_value\",\n display_name=\"Text\",\n info=\"Text to be passed as input and extract info from the CSV File.\",\n required=True,\n ),\n DictInput(\n name=\"pandas_kwargs\",\n display_name=\"Pandas Kwargs\",\n info=\"Pandas Kwargs to be passed to the agent.\",\n advanced=True,\n is_list=True,\n ),\n BoolInput(\n name=\"allow_dangerous_code\",\n display_name=\"Allow Dangerous Code\",\n value=False,\n required=True,\n info=(\n \"SECURITY WARNING: Enabling this allows the agent to execute arbitrary Python code \"\n \"on the server, which can lead to remote code execution vulnerabilities. \"\n \"Only enable this if you fully trust the input sources and understand the security implications. \"\n \"When disabled, the agent can still analyze CSV data but cannot execute custom Python code.\"\n ),\n ),\n ]\n\n outputs = [\n Output(display_name=\"Response\", name=\"response\", method=\"build_agent_response\"),\n Output(display_name=\"Agent\", name=\"agent\", method=\"build_agent\", hidden=True, tool_mode=False),\n ]\n\n def _path(self) -> str:\n if isinstance(self.path, Message) and isinstance(self.path.text, str):\n return self.path.text\n return self.path\n\n def _get_llm(self):\n \"\"\"Resolve the language model from dropdown selection or connected component.\"\"\"\n return get_llm(\n model=self.model,\n user_id=self.user_id,\n api_key=getattr(self, \"api_key\", None),\n watsonx_url=getattr(self, \"base_url_ibm_watsonx\", None),\n watsonx_project_id=getattr(self, \"project_id\", None),\n )\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n \"\"\"Dynamically update build config with user-filtered model options (tool-calling capable models).\"\"\"\n return handle_model_input_update(\n self,\n dict(build_config),\n field_value,\n field_name,\n cache_key_prefix=\"language_model_options_tool_calling\",\n get_options_func=lambda user_id=None: get_language_model_options(user_id=user_id, tool_calling=True),\n )\n\n def build_agent_response(self) -> Message:\n \"\"\"Build and execute the CSV agent, returning the response.\"\"\"\n try:\n from langchain_experimental.agents.agent_toolkits.csv.base import create_csv_agent\n except ImportError as e:\n msg = (\n \"langchain-experimental is not installed. Please install it with `pip install langchain-experimental`.\"\n )\n raise ImportError(msg) from e\n\n try:\n # Use False as default if allow_dangerous_code is not set (secure by default)\n allow_dangerous = getattr(self, \"allow_dangerous_code\", False) or False\n\n agent_kwargs = {\n \"verbose\": self.verbose,\n \"allow_dangerous_code\": allow_dangerous,\n }\n\n # Get local path (downloads from S3 if needed)\n local_path = self._get_local_path()\n llm = self._get_llm()\n\n agent_csv = create_csv_agent(\n llm=llm,\n path=local_path,\n agent_type=self.agent_type,\n handle_parsing_errors=self.handle_parsing_errors,\n pandas_kwargs=self.pandas_kwargs,\n **agent_kwargs,\n )\n\n result = agent_csv.invoke({\"input\": self.input_value})\n return Message(text=str(result[\"output\"]))\n\n finally:\n # Clean up temp file if created\n self._cleanup_temp_file()\n\n def build_agent(self) -> AgentExecutor:\n try:\n from langchain_experimental.agents.agent_toolkits.csv.base import create_csv_agent\n except ImportError as e:\n msg = (\n \"langchain-experimental is not installed. Please install it with `pip install langchain-experimental`.\"\n )\n raise ImportError(msg) from e\n\n # Use False as default if allow_dangerous_code is not set (secure by default)\n allow_dangerous = getattr(self, \"allow_dangerous_code\", False) or False\n\n agent_kwargs = {\n \"verbose\": self.verbose,\n \"allow_dangerous_code\": allow_dangerous,\n }\n\n # Get local path (downloads from S3 if needed)\n local_path = self._get_local_path()\n llm = self._get_llm()\n\n agent_csv = create_csv_agent(\n llm=llm,\n path=local_path,\n agent_type=self.agent_type,\n handle_parsing_errors=self.handle_parsing_errors,\n pandas_kwargs=self.pandas_kwargs,\n **agent_kwargs,\n )\n\n self.status = Message(text=str(agent_csv))\n\n # Note: Temp file will be cleaned up when the component is destroyed or\n # when build_agent_response is called\n return agent_csv\n\n def _get_local_path(self) -> str:\n \"\"\"Get a local file path, downloading from S3 storage if necessary.\n\n Returns:\n str: Local file path that can be used by LangChain\n \"\"\"\n file_path = self._path()\n settings = get_settings_service().settings\n\n # If using S3 storage, download the file to temp\n if settings.storage_type == \"s3\":\n # Download from S3 to temp file\n csv_bytes = run_until_complete(read_file_bytes(file_path))\n\n # Create temp file with .csv extension\n suffix = Path(file_path.split(\"/\")[-1]).suffix or \".csv\"\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=suffix, delete=False) as tmp_file:\n tmp_file.write(csv_bytes)\n temp_path = tmp_file.name\n\n # Store temp path for cleanup\n self._temp_file_path = temp_path\n return temp_path\n\n # Local storage - return path as-is\n return file_path\n\n def _cleanup_temp_file(self) -> None:\n \"\"\"Clean up temporary file if one was created.\"\"\"\n if hasattr(self, \"_temp_file_path\"):\n with contextlib.suppress(Exception):\n Path(self._temp_file_path).unlink() # Ignore cleanup errors\n" + "value": "import contextlib\nimport tempfile\nfrom pathlib import Path\n\nfrom lfx.base.agents.agent import LCAgentComponent\nfrom lfx.base.agents.utils import resolve_agent_verbose\nfrom lfx.base.data.storage_utils import read_file_bytes\nfrom lfx.base.models.unified_models import get_language_model_options, get_llm, handle_model_input_update\nfrom lfx.base.models.watsonx_constants import IBM_WATSONX_URLS\nfrom lfx.field_typing import AgentExecutor\nfrom lfx.inputs.inputs import (\n DictInput,\n DropdownInput,\n FileInput,\n MessageTextInput,\n ModelInput,\n)\nfrom lfx.io import BoolInput, SecretStrInput, StrInput\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.template.field.base import Output\nfrom lfx.utils.async_helpers import run_until_complete\n\n\nclass CSVAgentComponent(LCAgentComponent):\n display_name = \"CSV Agent\"\n description = \"Construct a CSV agent from a CSV and tools.\"\n documentation = \"https://python.langchain.com/docs/modules/agents/toolkits/csv\"\n name = \"CSVAgent\"\n icon = \"LangChain\"\n\n inputs = [\n *LCAgentComponent.get_base_inputs(),\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=\"Select your model provider or connect a Language Model component.\",\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Overrides global provider settings. Leave blank to use your pre-configured API Key.\",\n real_time_refresh=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"base_url_ibm_watsonx\",\n display_name=\"watsonx API Endpoint\",\n info=\"The base URL of the API (IBM watsonx.ai only)\",\n options=IBM_WATSONX_URLS,\n value=IBM_WATSONX_URLS[0],\n show=False,\n real_time_refresh=True,\n ),\n StrInput(\n name=\"project_id\",\n display_name=\"watsonx Project ID\",\n info=\"The project ID associated with the foundation model (IBM watsonx.ai only)\",\n show=False,\n required=False,\n ),\n FileInput(\n name=\"path\",\n display_name=\"File Path\",\n file_types=[\"csv\"],\n input_types=[\"str\", \"Message\"],\n required=True,\n info=\"A CSV File or File Path.\",\n ),\n DropdownInput(\n name=\"agent_type\",\n display_name=\"Agent Type\",\n advanced=True,\n options=[\"zero-shot-react-description\", \"openai-functions\", \"openai-tools\"],\n value=\"openai-tools\",\n ),\n MessageTextInput(\n name=\"input_value\",\n display_name=\"Text\",\n info=\"Text to be passed as input and extract info from the CSV File.\",\n required=True,\n ),\n DictInput(\n name=\"pandas_kwargs\",\n display_name=\"Pandas Kwargs\",\n info=\"Pandas Kwargs to be passed to the agent.\",\n advanced=True,\n is_list=True,\n ),\n BoolInput(\n name=\"allow_dangerous_code\",\n display_name=\"Allow Dangerous Code\",\n value=False,\n required=True,\n info=(\n \"SECURITY WARNING: Enabling this allows the agent to execute arbitrary Python code \"\n \"on the server, which can lead to remote code execution vulnerabilities. \"\n \"Only enable this if you fully trust the input sources and understand the security implications. \"\n \"When disabled, the agent can still analyze CSV data but cannot execute custom Python code.\"\n ),\n ),\n ]\n\n outputs = [\n Output(display_name=\"Response\", name=\"response\", method=\"build_agent_response\"),\n Output(display_name=\"Agent\", name=\"agent\", method=\"build_agent\", hidden=True, tool_mode=False),\n ]\n\n def _path(self) -> str:\n if isinstance(self.path, Message) and isinstance(self.path.text, str):\n return self.path.text\n return self.path\n\n def _get_llm(self):\n \"\"\"Resolve the language model from dropdown selection or connected component.\"\"\"\n return get_llm(\n model=self.model,\n user_id=self.user_id,\n api_key=getattr(self, \"api_key\", None),\n watsonx_url=getattr(self, \"base_url_ibm_watsonx\", None),\n watsonx_project_id=getattr(self, \"project_id\", None),\n )\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None) -> dict:\n \"\"\"Dynamically update build config with user-filtered model options (tool-calling capable models).\"\"\"\n return handle_model_input_update(\n self,\n dict(build_config),\n field_value,\n field_name,\n cache_key_prefix=\"language_model_options_tool_calling\",\n get_options_func=lambda user_id=None: get_language_model_options(user_id=user_id, tool_calling=True),\n )\n\n def build_agent_response(self) -> Message:\n \"\"\"Build and execute the CSV agent, returning the response.\"\"\"\n try:\n from langchain_experimental.agents.agent_toolkits.csv.base import create_csv_agent\n except ImportError as e:\n msg = (\n \"langchain-experimental is not installed. Please install it with `pip install langchain-experimental`.\"\n )\n raise ImportError(msg) from e\n\n try:\n # Use False as default if allow_dangerous_code is not set (secure by default)\n allow_dangerous = getattr(self, \"allow_dangerous_code\", False) or False\n\n agent_kwargs = {\n # Gate LangChain's stdout chain markers on LANGCHAIN_VERBOSE (off by\n # default) instead of the always-True component input. See\n # resolve_agent_verbose().\n \"verbose\": resolve_agent_verbose(),\n \"allow_dangerous_code\": allow_dangerous,\n }\n\n # Get local path (downloads from S3 if needed)\n local_path = self._get_local_path()\n llm = self._get_llm()\n\n agent_csv = create_csv_agent(\n llm=llm,\n path=local_path,\n agent_type=self.agent_type,\n handle_parsing_errors=self.handle_parsing_errors,\n pandas_kwargs=self.pandas_kwargs,\n **agent_kwargs,\n )\n\n result = agent_csv.invoke({\"input\": self.input_value})\n return Message(text=str(result[\"output\"]))\n\n finally:\n # Clean up temp file if created\n self._cleanup_temp_file()\n\n def build_agent(self) -> AgentExecutor:\n try:\n from langchain_experimental.agents.agent_toolkits.csv.base import create_csv_agent\n except ImportError as e:\n msg = (\n \"langchain-experimental is not installed. Please install it with `pip install langchain-experimental`.\"\n )\n raise ImportError(msg) from e\n\n # Use False as default if allow_dangerous_code is not set (secure by default)\n allow_dangerous = getattr(self, \"allow_dangerous_code\", False) or False\n\n agent_kwargs = {\n # Gate LangChain's stdout chain markers on LANGCHAIN_VERBOSE (off by\n # default) instead of the always-True component input. See\n # resolve_agent_verbose().\n \"verbose\": resolve_agent_verbose(),\n \"allow_dangerous_code\": allow_dangerous,\n }\n\n # Get local path (downloads from S3 if needed)\n local_path = self._get_local_path()\n llm = self._get_llm()\n\n agent_csv = create_csv_agent(\n llm=llm,\n path=local_path,\n agent_type=self.agent_type,\n handle_parsing_errors=self.handle_parsing_errors,\n pandas_kwargs=self.pandas_kwargs,\n **agent_kwargs,\n )\n\n self.status = Message(text=str(agent_csv))\n\n # Note: Temp file will be cleaned up when the component is destroyed or\n # when build_agent_response is called\n return agent_csv\n\n def _get_local_path(self) -> str:\n \"\"\"Get a local file path, downloading from S3 storage if necessary.\n\n Returns:\n str: Local file path that can be used by LangChain\n \"\"\"\n file_path = self._path()\n settings = get_settings_service().settings\n\n # If using S3 storage, download the file to temp\n if settings.storage_type == \"s3\":\n # Download from S3 to temp file\n csv_bytes = run_until_complete(read_file_bytes(file_path))\n\n # Create temp file with .csv extension\n suffix = Path(file_path.split(\"/\")[-1]).suffix or \".csv\"\n with tempfile.NamedTemporaryFile(mode=\"wb\", suffix=suffix, delete=False) as tmp_file:\n tmp_file.write(csv_bytes)\n temp_path = tmp_file.name\n\n # Store temp path for cleanup\n self._temp_file_path = temp_path\n return temp_path\n\n # Local storage - return path as-is\n return file_path\n\n def _cleanup_temp_file(self) -> None:\n \"\"\"Clean up temporary file if one was created.\"\"\"\n if hasattr(self, \"_temp_file_path\"):\n with contextlib.suppress(Exception):\n Path(self._temp_file_path).unlink() # Ignore cleanup errors\n" }, "handle_parsing_errors": { "_input_type": "BoolInput", @@ -13123,7 +13129,7 @@ "advanced": true, "display_name": "Verbose", "dynamic": false, - "info": "", + "info": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "list": false, "list_add_label": "Add More", "name": "verbose", @@ -13830,7 +13836,7 @@ "advanced": true, "display_name": "Verbose", "dynamic": false, - "info": "", + "info": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "list": false, "list_add_label": "Add More", "name": "verbose", @@ -14136,7 +14142,7 @@ }, { "name": "langchain", - "version": "1.2.18" + "version": "1.3.7" } ], "total_dependencies": 3 @@ -15104,7 +15110,7 @@ "advanced": true, "display_name": "Verbose", "dynamic": false, - "info": "", + "info": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "list": false, "list_add_label": "Add More", "name": "verbose", @@ -15454,7 +15460,7 @@ "advanced": true, "display_name": "Verbose", "dynamic": false, - "info": "", + "info": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "list": false, "list_add_label": "Add More", "name": "verbose", @@ -16395,7 +16401,7 @@ "advanced": true, "display_name": "Verbose", "dynamic": false, - "info": "", + "info": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "list": false, "list_add_label": "Add More", "name": "verbose", @@ -17512,7 +17518,7 @@ "advanced": true, "display_name": "Verbose", "dynamic": false, - "info": "", + "info": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "list": false, "list_add_label": "Add More", "name": "verbose", @@ -17890,7 +17896,7 @@ "advanced": true, "display_name": "Verbose", "dynamic": false, - "info": "", + "info": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "list": false, "list_add_label": "Add More", "name": "verbose", @@ -18296,7 +18302,7 @@ "advanced": true, "display_name": "Verbose", "dynamic": false, - "info": "", + "info": "Legacy toggle. The '> Entering new ... chain' / '> Finished chain.' markers it used to print to stdout are now gated on the LANGCHAIN_VERBOSE environment variable (off by default); set LANGCHAIN_VERBOSE=true to emit them. Toggling this input on its own no longer attaches LangChain's stdout handler. Agent steps remain visible in the UI regardless of this setting.", "list": false, "list_add_label": "Add More", "name": "verbose", @@ -19503,7 +19509,7 @@ "icon": "route", "legacy": false, "metadata": { - "code_hash": "de94f8c46838", + "code_hash": "27437c3b6527", "dependencies": { "dependencies": [ { @@ -19557,7 +19563,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from typing import Any\n\nfrom lfx.base.models.unified_models import (\n get_llm,\n handle_model_input_update,\n)\nfrom lfx.custom import Component\nfrom lfx.io import (\n BoolInput,\n MessageInput,\n MessageTextInput,\n ModelInput,\n MultilineInput,\n Output,\n SecretStrInput,\n TableInput,\n)\nfrom lfx.schema.message import Message\nfrom lfx.schema.table import EditMode\nfrom lfx.schema.token_usage import extract_usage_from_message\n\n\nclass SmartRouterComponent(Component):\n display_name = \"Smart Router\"\n description = \"Routes an input message using LLM-based categorization.\"\n icon = \"route\"\n name = \"SmartRouter\"\n\n def __init__(self, **kwargs):\n super().__init__(**kwargs)\n self._matched_category = None\n self._categorization_result: str | None = None\n\n inputs = [\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=\"Select your model provider\",\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Overrides global provider settings. Leave blank to use your pre-configured API Key.\",\n real_time_refresh=True,\n advanced=True,\n ),\n MessageTextInput(\n name=\"input_text\",\n display_name=\"Input\",\n info=\"The primary text input for the operation.\",\n required=True,\n ),\n TableInput(\n name=\"routes\",\n display_name=\"Routes\",\n info=(\n \"Define the categories for routing. Each row should have a route/category name \"\n \"and optionally a custom output value.\"\n ),\n table_schema=[\n {\n \"name\": \"route_category\",\n \"display_name\": \"Route Name\",\n \"type\": \"str\",\n \"description\": \"Name for the route (used for both output name and category matching)\",\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"route_description\",\n \"display_name\": \"Route Description\",\n \"type\": \"str\",\n \"description\": \"Description of when this route should be used (helps LLM understand the category)\",\n \"default\": \"\",\n \"edit_mode\": EditMode.POPOVER,\n },\n {\n \"name\": \"output_value\",\n \"display_name\": \"Route Message (Optional)\",\n \"type\": \"str\",\n \"description\": (\n \"Optional message to send when this route is matched.\"\n \"Leave empty to pass through the original input text.\"\n ),\n \"default\": \"\",\n \"edit_mode\": EditMode.POPOVER,\n },\n ],\n value=[\n {\n \"route_category\": \"Positive\",\n \"route_description\": \"Positive feedback, satisfaction, or compliments\",\n \"output_value\": \"\",\n },\n {\n \"route_category\": \"Negative\",\n \"route_description\": \"Complaints, issues, or dissatisfaction\",\n \"output_value\": \"\",\n },\n ],\n real_time_refresh=True,\n required=True,\n ),\n MessageInput(\n name=\"message\",\n display_name=\"Override Output\",\n info=(\n \"Optional override message that will replace both the Input and Output Value \"\n \"for all routes when filled.\"\n ),\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"enable_else_output\",\n display_name=\"Include Else Output\",\n info=\"Include an Else output for cases that don't match any route.\",\n value=False,\n advanced=True,\n real_time_refresh=True,\n ),\n MultilineInput(\n name=\"custom_prompt\",\n display_name=\"Additional Instructions\",\n info=(\n \"Additional instructions for LLM-based categorization. \"\n \"These will be added to the base prompt. \"\n \"Use {input_text} for the input text and {routes} for the available categories.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs: list[Output] = []\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Dynamically update build config with user-filtered model options.\"\"\"\n return handle_model_input_update(self, build_config, field_value, field_name)\n\n def update_outputs(self, frontend_node: dict, field_name: str, field_value: Any) -> dict:\n \"\"\"Create a dynamic output for each category in the categories table.\"\"\"\n if field_name in {\"routes\", \"enable_else_output\", \"model\"}:\n frontend_node[\"outputs\"] = []\n\n # Get the routes data - either from field_value (if routes field) or from component state\n routes_data = field_value if field_name == \"routes\" else getattr(self, \"routes\", [])\n\n # Add a dynamic output for each category - all using the same method\n for i, row in enumerate(routes_data):\n route_category = row.get(\"route_category\", f\"Category {i + 1}\")\n frontend_node[\"outputs\"].append(\n Output(\n display_name=route_category,\n name=f\"category_{i + 1}_result\",\n method=\"process_case\",\n group_outputs=True,\n )\n )\n # Add default output only if enabled\n if field_name == \"enable_else_output\":\n enable_else = field_value\n else:\n enable_else = getattr(self, \"enable_else_output\", False)\n\n if enable_else:\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Else\", name=\"default_result\", method=\"default_response\", group_outputs=True)\n )\n return frontend_node\n\n def _get_categorization(self) -> str:\n \"\"\"Perform LLM categorization and cache the result.\n\n This ensures the LLM is called only once per component execution,\n regardless of how many outputs are connected.\n \"\"\"\n # Return cached result if available\n if self._categorization_result is not None:\n return self._categorization_result\n\n categories = getattr(self, \"routes\", [])\n input_text = getattr(self, \"input_text\", \"\")\n llm = get_llm(model=self.model, user_id=self.user_id, api_key=self.api_key)\n\n if not llm or not categories:\n self.status = \"No LLM provided for categorization\"\n self._categorization_result = \"NONE\"\n return self._categorization_result\n\n # Create prompt for categorization\n category_info = []\n for i, category in enumerate(categories):\n cat_name = category.get(\"route_category\", f\"Category {i + 1}\")\n cat_desc = category.get(\"route_description\", \"\")\n if cat_desc and cat_desc.strip():\n category_info.append(f'\"{cat_name}\": {cat_desc}')\n else:\n category_info.append(f'\"{cat_name}\"')\n\n categories_text = \"\\n\".join([f\"- {info}\" for info in category_info if info])\n\n # Create base prompt\n base_prompt = (\n f\"You are a text classifier. Given the following text and categories, \"\n f\"determine which category best matches the text.\\n\\n\"\n f'Text to classify: \"{input_text}\"\\n\\n'\n f\"Available categories:\\n{categories_text}\\n\\n\"\n f\"Respond with ONLY the exact category name that best matches the text. \"\n f'If none match well, respond with \"NONE\".\\n\\n'\n f\"Category:\"\n )\n\n # Use custom prompt as additional instructions if provided\n custom_prompt = getattr(self, \"custom_prompt\", \"\")\n if custom_prompt and custom_prompt.strip():\n self.status = \"Using custom prompt as additional instructions\"\n simple_routes = \", \".join(\n [f'\"{cat.get(\"route_category\", f\"Category {i + 1}\")}\"' for i, cat in enumerate(categories)]\n )\n formatted_custom = custom_prompt.format(input_text=input_text, routes=simple_routes)\n prompt = f\"{base_prompt}\\n\\nAdditional Instructions:\\n{formatted_custom}\"\n else:\n self.status = \"Using default prompt for LLM categorization\"\n prompt = base_prompt\n\n self.status = f\"Prompt sent to LLM:\\n{prompt}\"\n\n try:\n if hasattr(llm, \"invoke\"):\n response = llm.invoke(prompt)\n self._token_usage = extract_usage_from_message(response)\n if hasattr(response, \"content\"):\n categorization = response.content.strip().strip('\"')\n else:\n categorization = str(response).strip().strip('\"')\n else:\n categorization = str(llm(prompt)).strip().strip('\"')\n\n self.status = f\"LLM response: '{categorization}'\"\n self._categorization_result = categorization\n except RuntimeError as e:\n self.status = f\"Error in LLM categorization: {e!s}\"\n self._categorization_result = \"NONE\"\n\n return self._categorization_result\n\n def process_case(self) -> Message:\n \"\"\"Process all categories using LLM categorization and return message for matching category.\"\"\"\n # Clear any previous match state (only on first call)\n if self._categorization_result is None:\n self._matched_category = None\n\n # Get categories and input text\n categories = getattr(self, \"routes\", [])\n input_text = getattr(self, \"input_text\", \"\")\n\n # Get the cached categorization result (performs LLM call only once)\n categorization = self._get_categorization()\n\n # Find matching category based on LLM response\n matched_category = None\n for i, category in enumerate(categories):\n route_category = category.get(\"route_category\", \"\")\n if categorization.lower() == route_category.lower():\n matched_category = i\n self.status = f\"MATCH FOUND! Category {i + 1} matched with '{categorization}'\"\n break\n\n if matched_category is not None:\n # Store the matched category for other outputs to check\n self._matched_category = matched_category\n\n # Stop all category outputs except the matched one\n for i in range(len(categories)):\n if i != matched_category:\n self.stop(f\"category_{i + 1}_result\")\n\n # Also stop the default output (if it exists)\n enable_else = getattr(self, \"enable_else_output\", False)\n if enable_else:\n self.stop(\"default_result\")\n\n route_category = categories[matched_category].get(\"route_category\", f\"Category {matched_category + 1}\")\n self.status = f\"Categorized as {route_category}\"\n\n # Check if there's an override output (takes precedence over everything)\n override_output = getattr(self, \"message\", None)\n if (\n override_output\n and hasattr(override_output, \"text\")\n and override_output.text\n and str(override_output.text).strip()\n ):\n return Message(text=str(override_output.text))\n if override_output and isinstance(override_output, str) and override_output.strip():\n return Message(text=str(override_output))\n\n # Check if there's a custom output value for this category\n custom_output = categories[matched_category].get(\"output_value\", \"\")\n # Treat None, empty string, or whitespace as blank\n if custom_output and str(custom_output).strip() and str(custom_output).strip().lower() != \"none\":\n # Use custom output value\n return Message(text=str(custom_output))\n # Use input as default output\n return Message(text=input_text)\n # No match found, stop all category outputs\n for i in range(len(categories)):\n self.stop(f\"category_{i + 1}_result\")\n\n # Check if else output is enabled\n enable_else = getattr(self, \"enable_else_output\", False)\n if enable_else:\n # The default_response will handle the else case\n self.stop(\"process_case\")\n return Message(text=\"\")\n # No else output, so no output at all\n self.status = \"No match found and Else output is disabled\"\n return Message(text=\"\")\n\n def default_response(self) -> Message:\n \"\"\"Handle the else case when no conditions match.\"\"\"\n enable_else = getattr(self, \"enable_else_output\", False)\n if not enable_else:\n self.status = \"Else output is disabled\"\n return Message(text=\"\")\n\n categories = getattr(self, \"routes\", [])\n input_text = getattr(self, \"input_text\", \"\")\n\n # Get the cached categorization result (performs LLM call only if not already done)\n categorization = self._get_categorization()\n\n # Check if the categorization matches any category\n has_match = False\n for i, category in enumerate(categories):\n route_category = category.get(\"route_category\", \"\")\n if categorization.lower() == route_category.lower():\n has_match = True\n self.status = f\"Match found for '{categorization}' (Category {i + 1}), stopping default_response\"\n break\n\n if has_match:\n # A case matches, stop this output\n self.stop(\"default_result\")\n return Message(text=\"\")\n\n # No case matches, check for override output first, then use input as default\n override_output = getattr(self, \"message\", None)\n if (\n override_output\n and hasattr(override_output, \"text\")\n and override_output.text\n and str(override_output.text).strip()\n ):\n self.status = \"Routed to Else (no match) - using override output\"\n return Message(text=str(override_output.text))\n if override_output and isinstance(override_output, str) and override_output.strip():\n self.status = \"Routed to Else (no match) - using override output\"\n return Message(text=str(override_output))\n\n self.status = \"Routed to Else (no match) - using input as default\"\n return Message(text=input_text)\n" + "value": "from typing import Any\n\nfrom lfx.base.models.unified_models import (\n get_llm,\n handle_model_input_update,\n)\nfrom lfx.custom import Component\nfrom lfx.io import (\n BoolInput,\n MessageInput,\n MessageTextInput,\n ModelInput,\n MultilineInput,\n Output,\n SecretStrInput,\n TableInput,\n)\nfrom lfx.schema.message import Message\nfrom lfx.schema.table import EditMode\nfrom lfx.schema.token_usage import extract_usage_from_message\n\n\nclass SmartRouterComponent(Component):\n display_name = \"Smart Router\"\n description = \"Routes an input message using LLM-based categorization.\"\n icon = \"route\"\n name = \"SmartRouter\"\n\n def __init__(self, **kwargs):\n super().__init__(**kwargs)\n self._matched_category = None\n self._categorization_result: str | None = None\n self._excluded_outputs: set[str] = set()\n\n inputs = [\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=\"Select your model provider\",\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Overrides global provider settings. Leave blank to use your pre-configured API Key.\",\n real_time_refresh=True,\n advanced=True,\n ),\n MessageTextInput(\n name=\"input_text\",\n display_name=\"Input\",\n info=\"The primary text input for the operation.\",\n required=True,\n ),\n TableInput(\n name=\"routes\",\n display_name=\"Routes\",\n info=(\n \"Define the categories for routing. Each row should have a route/category name \"\n \"and optionally a custom output value.\"\n ),\n table_schema=[\n {\n \"name\": \"route_category\",\n \"display_name\": \"Route Name\",\n \"type\": \"str\",\n \"description\": \"Name for the route (used for both output name and category matching)\",\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"route_description\",\n \"display_name\": \"Route Description\",\n \"type\": \"str\",\n \"description\": \"Description of when this route should be used (helps LLM understand the category)\",\n \"default\": \"\",\n \"edit_mode\": EditMode.POPOVER,\n },\n {\n \"name\": \"output_value\",\n \"display_name\": \"Route Message (Optional)\",\n \"type\": \"str\",\n \"description\": (\n \"Optional message to send when this route is matched.\"\n \"Leave empty to pass through the original input text.\"\n ),\n \"default\": \"\",\n \"edit_mode\": EditMode.POPOVER,\n },\n ],\n value=[\n {\n \"route_category\": \"Positive\",\n \"route_description\": \"Positive feedback, satisfaction, or compliments\",\n \"output_value\": \"\",\n },\n {\n \"route_category\": \"Negative\",\n \"route_description\": \"Complaints, issues, or dissatisfaction\",\n \"output_value\": \"\",\n },\n ],\n real_time_refresh=True,\n required=True,\n ),\n MessageInput(\n name=\"message\",\n display_name=\"Override Output\",\n info=(\n \"Optional override message that will replace both the Input and Output Value \"\n \"for all routes when filled.\"\n ),\n required=False,\n advanced=True,\n ),\n BoolInput(\n name=\"enable_else_output\",\n display_name=\"Include Else Output\",\n info=\"Include an Else output for cases that don't match any route.\",\n value=False,\n advanced=True,\n real_time_refresh=True,\n ),\n MultilineInput(\n name=\"custom_prompt\",\n display_name=\"Additional Instructions\",\n info=(\n \"Additional instructions for LLM-based categorization. \"\n \"These will be added to the base prompt. \"\n \"Use {input_text} for the input text and {routes} for the available categories.\"\n ),\n advanced=True,\n ),\n ]\n\n outputs: list[Output] = []\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Dynamically update build config with user-filtered model options.\"\"\"\n return handle_model_input_update(self, build_config, field_value, field_name)\n\n def update_outputs(self, frontend_node: dict, field_name: str, field_value: Any) -> dict:\n \"\"\"Create a dynamic output for each category in the categories table.\"\"\"\n if field_name in {\"routes\", \"enable_else_output\", \"model\"}:\n frontend_node[\"outputs\"] = []\n\n # Get the routes data - either from field_value (if routes field) or from component state\n routes_data = field_value if field_name == \"routes\" else getattr(self, \"routes\", [])\n\n # Add a dynamic output for each category - all using the same method\n for i, row in enumerate(routes_data):\n route_category = row.get(\"route_category\", f\"Category {i + 1}\")\n frontend_node[\"outputs\"].append(\n Output(\n display_name=route_category,\n name=f\"category_{i + 1}_result\",\n method=\"process_case\",\n group_outputs=True,\n )\n )\n # Add default output only if enabled\n if field_name == \"enable_else_output\":\n enable_else = field_value\n else:\n enable_else = getattr(self, \"enable_else_output\", False)\n\n if enable_else:\n frontend_node[\"outputs\"].append(\n Output(display_name=\"Else\", name=\"default_result\", method=\"default_response\", group_outputs=True)\n )\n return frontend_node\n\n def _get_categorization(self) -> str:\n \"\"\"Perform LLM categorization and cache the result.\n\n This ensures the LLM is called only once per component execution,\n regardless of how many outputs are connected.\n \"\"\"\n # Return cached result if available\n if self._categorization_result is not None:\n return self._categorization_result\n\n categories = getattr(self, \"routes\", [])\n input_text = getattr(self, \"input_text\", \"\")\n llm = get_llm(model=self.model, user_id=self.user_id, api_key=self.api_key)\n\n if not llm or not categories:\n self.status = \"No LLM provided for categorization\"\n self._categorization_result = \"NONE\"\n return self._categorization_result\n\n # Create prompt for categorization\n category_info = []\n for i, category in enumerate(categories):\n cat_name = category.get(\"route_category\", f\"Category {i + 1}\")\n cat_desc = category.get(\"route_description\", \"\")\n if cat_desc and cat_desc.strip():\n category_info.append(f'\"{cat_name}\": {cat_desc}')\n else:\n category_info.append(f'\"{cat_name}\"')\n\n categories_text = \"\\n\".join([f\"- {info}\" for info in category_info if info])\n\n # Create base prompt\n base_prompt = (\n f\"You are a text classifier. Given the following text and categories, \"\n f\"determine which category best matches the text.\\n\\n\"\n f'Text to classify: \"{input_text}\"\\n\\n'\n f\"Available categories:\\n{categories_text}\\n\\n\"\n f\"Respond with ONLY the exact category name that best matches the text. \"\n f'If none match well, respond with \"NONE\".\\n\\n'\n f\"Category:\"\n )\n\n # Use custom prompt as additional instructions if provided\n custom_prompt = getattr(self, \"custom_prompt\", \"\")\n if custom_prompt and custom_prompt.strip():\n self.status = \"Using custom prompt as additional instructions\"\n simple_routes = \", \".join(\n [f'\"{cat.get(\"route_category\", f\"Category {i + 1}\")}\"' for i, cat in enumerate(categories)]\n )\n formatted_custom = custom_prompt.format(input_text=input_text, routes=simple_routes)\n prompt = f\"{base_prompt}\\n\\nAdditional Instructions:\\n{formatted_custom}\"\n else:\n self.status = \"Using default prompt for LLM categorization\"\n prompt = base_prompt\n\n self.status = f\"Prompt sent to LLM:\\n{prompt}\"\n\n try:\n if hasattr(llm, \"invoke\"):\n response = llm.invoke(prompt)\n self._token_usage = extract_usage_from_message(response)\n if hasattr(response, \"content\"):\n categorization = response.content.strip().strip('\"')\n else:\n categorization = str(response).strip().strip('\"')\n else:\n categorization = str(llm(prompt)).strip().strip('\"')\n\n self.status = f\"LLM response: '{categorization}'\"\n self._categorization_result = categorization\n except RuntimeError as e:\n self.status = f\"Error in LLM categorization: {e!s}\"\n self._categorization_result = \"NONE\"\n\n return self._categorization_result\n\n def _pre_run_setup(self) -> None:\n \"\"\"Reset per-run routing state before each build.\n\n Called once per vertex build (before the output methods run), so the categorization\n and branch-exclusion decisions are recomputed fresh on every execution.\n \"\"\"\n self._matched_category = None\n self._categorization_result = None\n self._excluded_outputs = set()\n\n def _deactivate_branches(self, output_names: list[str]) -> None:\n \"\"\"Deactivate the given output branches so their downstream nodes do not execute.\n\n Two complementary mechanisms are used (mirroring ConditionalRouterComponent):\n\n * ``stop()`` marks each branch INACTIVE for the current scheduling pass. It also\n detaches the branch from any shared downstream node's pending-predecessor list,\n so that node can still run from the *selected* branch.\n * ``exclude_branches_conditionally()`` records a *persistent* exclusion. The INACTIVE\n state is reset between scheduling passes, so without this a re-activated branch that\n reconverges on a shared downstream node gets picked up again and executes -- the\n unselected-branch-runs bug this guards against.\n\n Exclusions accumulate across calls (``process_case`` runs once per connected output,\n plus ``default_response``) so excluding one branch never clears its siblings.\n \"\"\"\n for name in output_names:\n self.stop(name)\n # The persistent exclusion needs a real vertex/graph. Skip it when the component is\n # exercised without one (e.g. direct unit tests that mock ``stop``).\n if self._vertex is None:\n return\n self._excluded_outputs.update(output_names)\n self._vertex.graph.exclude_branches_conditionally(self._id, sorted(self._excluded_outputs))\n\n def process_case(self) -> Message:\n \"\"\"Process all categories using LLM categorization and return message for matching category.\"\"\"\n # Clear any previous match state (only on first call)\n if self._categorization_result is None:\n self._matched_category = None\n\n # Get categories and input text\n categories = getattr(self, \"routes\", [])\n input_text = getattr(self, \"input_text\", \"\")\n\n # Get the cached categorization result (performs LLM call only once)\n categorization = self._get_categorization()\n\n # Find matching category based on LLM response\n matched_category = None\n for i, category in enumerate(categories):\n route_category = category.get(\"route_category\", \"\")\n if categorization.lower() == route_category.lower():\n matched_category = i\n self.status = f\"MATCH FOUND! Category {i + 1} matched with '{categorization}'\"\n break\n\n if matched_category is not None:\n # Store the matched category for other outputs to check\n self._matched_category = matched_category\n\n # Deactivate all category outputs except the matched one, plus the default\n # output if present, so only the matched branch continues downstream.\n outputs_to_deactivate = [\n f\"category_{i + 1}_result\" for i in range(len(categories)) if i != matched_category\n ]\n enable_else = getattr(self, \"enable_else_output\", False)\n if enable_else:\n outputs_to_deactivate.append(\"default_result\")\n self._deactivate_branches(outputs_to_deactivate)\n\n route_category = categories[matched_category].get(\"route_category\", f\"Category {matched_category + 1}\")\n self.status = f\"Categorized as {route_category}\"\n matched_output_name = f\"category_{matched_category + 1}_result\"\n if self._current_output and self._current_output != matched_output_name:\n return Message(text=\"\")\n\n # Check if there's an override output (takes precedence over everything)\n override_output = getattr(self, \"message\", None)\n if (\n override_output\n and hasattr(override_output, \"text\")\n and override_output.text\n and str(override_output.text).strip()\n ):\n return Message(text=str(override_output.text))\n if override_output and isinstance(override_output, str) and override_output.strip():\n return Message(text=str(override_output))\n\n # Check if there's a custom output value for this category\n custom_output = categories[matched_category].get(\"output_value\", \"\")\n # Treat None, empty string, or whitespace as blank\n if custom_output and str(custom_output).strip() and str(custom_output).strip().lower() != \"none\":\n # Use custom output value\n return Message(text=str(custom_output))\n # Use input as default output\n return Message(text=input_text)\n # No match found: deactivate every category output so none continue downstream.\n # The default/Else output (if enabled) is intentionally left active and handled by\n # default_response.\n self._deactivate_branches([f\"category_{i + 1}_result\" for i in range(len(categories))])\n\n # Check if else output is enabled\n enable_else = getattr(self, \"enable_else_output\", False)\n if enable_else:\n # The default_response will handle the else case\n self.stop(\"process_case\")\n return Message(text=\"\")\n # No else output, so no output at all\n self.status = \"No match found and Else output is disabled\"\n return Message(text=\"\")\n\n def default_response(self) -> Message:\n \"\"\"Handle the else case when no conditions match.\"\"\"\n enable_else = getattr(self, \"enable_else_output\", False)\n if not enable_else:\n self.status = \"Else output is disabled\"\n return Message(text=\"\")\n\n categories = getattr(self, \"routes\", [])\n input_text = getattr(self, \"input_text\", \"\")\n\n # Get the cached categorization result (performs LLM call only if not already done)\n categorization = self._get_categorization()\n\n # Check if the categorization matches any category\n has_match = False\n for i, category in enumerate(categories):\n route_category = category.get(\"route_category\", \"\")\n if categorization.lower() == route_category.lower():\n has_match = True\n self.status = f\"Match found for '{categorization}' (Category {i + 1}), stopping default_response\"\n break\n\n if has_match:\n # A case matches, so the Else branch must not continue downstream.\n self._deactivate_branches([\"default_result\"])\n return Message(text=\"\")\n\n # No case matches, check for override output first, then use input as default\n override_output = getattr(self, \"message\", None)\n if (\n override_output\n and hasattr(override_output, \"text\")\n and override_output.text\n and str(override_output.text).strip()\n ):\n self.status = \"Routed to Else (no match) - using override output\"\n return Message(text=str(override_output.text))\n if override_output and isinstance(override_output, str) and override_output.strip():\n self.status = \"Routed to Else (no match) - using override output\"\n return Message(text=str(override_output))\n\n self.status = \"Routed to Else (no match) - using input as default\"\n return Message(text=input_text)\n" }, "custom_prompt": { "_input_type": "MultilineInput", @@ -20124,7 +20130,7 @@ "dependencies": [ { "name": "langchain", - "version": "1.2.18" + "version": "1.3.7" }, { "name": "lfx", @@ -22210,7 +22216,7 @@ "icon": "shield-check", "legacy": false, "metadata": { - "code_hash": "15af226d3e92", + "code_hash": "1c913edea4a2", "dependencies": { "dependencies": [ { @@ -22219,7 +22225,7 @@ }, { "name": "toolguard", - "version": "0.2.19" + "version": "0.2.20" } ], "total_dependencies": 2 @@ -22288,7 +22294,7 @@ "show": true, "title_case": false, "type": "code", - "value": "from __future__ import annotations\n\nimport os\nimport re\nimport shutil\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, cast\n\nfrom lfx.base.models import LCModelComponent\nfrom lfx.base.models.unified_models import (\n get_language_model_options,\n get_llm,\n update_model_options_in_build_config,\n)\nfrom lfx.components.models_and_agents.policies.module_utils import unload_module\nfrom lfx.field_typing import LanguageModel, Tool\nfrom lfx.io import (\n BoolInput,\n HandleInput,\n ModelInput,\n MultilineInput,\n Output,\n SecretStrInput,\n StrInput,\n TabInput,\n)\nfrom lfx.log.logger import logger\n\nif TYPE_CHECKING:\n from toolguard.buildtime import ToolGuardsCodeGenerationResult, ToolGuardSpec\n\n from lfx.inputs.inputs import InputTypes\n\n\nTOOLGUARD_WORK_DIR = Path(os.getenv(\"TOOLGUARD_WORK_DIR\") or \"tmp_toolguard\")\nBUILDTIME_MODELS = [\"gpt-5\", \"claude-sonnet\"] # currently inactive, we recommend but do not enforce\nSTEP1 = \"Step_1\"\nSTEP2 = \"Step_2\"\nMODE_GENERATE = \"🛠️ Generate\"\nMODE_GUARD = \"🛡️ Guard\"\nGENERATED_GUARD_INFO_PREFIX = \"Auto-generated ToolGuard code for \"\n\n_TOOLGUARD_INSTALL_HINT = (\n \"The 'toolguard' package is required to use PoliciesComponent. \"\n \"Install the optional extra: `pip install 'langflow-base[toolguard]'`.\"\n)\n\n\nclass PoliciesComponent(LCModelComponent):\n \"\"\"Component for building tool protection code from textual business policies and instructions.\n\n This component uses ToolGuard to generate and apply policy-based guards to tools,\n ensuring that tool execution complies with defined business policies.\n Powered by ALTK ToolGuard (https://github.com/AgentToolkit/toolguard).\n\n `toolguard` is an optional extra (`langflow-base[toolguard]`); imports happen\n lazily inside methods so this component can be discovered and inspected even\n when the extra isn't installed.\n \"\"\"\n\n display_name = \"Policies\"\n description = \"\"\"Component for building tool protection code from textual business policies and instructions.\nPowered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )\"\"\"\n documentation: str = \"https://github.com/AgentToolkit/toolguard\"\n icon = \"shield-check\"\n name = \"policies\"\n beta = True\n\n inputs = cast(\n \"list[InputTypes]\",\n [\n BoolInput(\n name=\"enabled\",\n display_name=\"Enabled\",\n info=\"If `true` - guards tool calls. If `false`, skip policy validation.\",\n value=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Activity\",\n options=[MODE_GENERATE, MODE_GUARD],\n info=(\n \"Generate new guard code or apply existing guard. \"\n \"Review generated files in the details panel on the right.\"\n ),\n value=MODE_GENERATE,\n real_time_refresh=True,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"project\",\n display_name=\"Policies Project\",\n info=\"Folder name of the generated code\",\n value=\"my_project\",\n # required=True,\n ),\n HandleInput(\n name=\"in_tools\",\n display_name=\"Tools\",\n input_types=[\"Tool\"],\n is_list=True,\n required=True,\n info=\"These are the tools that the agent can use to help with tasks.\",\n ),\n StrInput(\n name=\"policies\",\n display_name=\"Policies\",\n info=\"One or more clear, well-defined and self-contained business policies\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Add business policy...\",\n list_add_label=\"Add Policy\",\n # input_types=[],\n ),\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=(\n \"Select LLM for Policies buildtime. We recommend using \"\n \"Anthropic Claude-Sonnet series for this task.\"\n ),\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Model Provider API key\",\n required=False,\n advanced=True,\n ),\n ],\n )\n outputs = [\n Output(\n display_name=\"Guarded Tools\",\n type_=Tool,\n name=\"guarded_tools\",\n method=\"guard_tools\",\n # group_outputs=True,\n ),\n ]\n\n @staticmethod\n def _import_toolguard():\n \"\"\"Lazily import `toolguard` and the sibling helpers that depend on it.\n\n Defined as a static method so it survives custom-component re-execution\n via `create_class`, which only re-executes the class body, not arbitrary\n module-level statements such as `try/except` import guards.\n \"\"\"\n try:\n from toolguard.buildtime import (\n PolicySpecOptions,\n ToolGuardsCodeGenerationResult,\n generate_guard_specs,\n generate_guards_code,\n )\n from toolguard.extra.langchain_to_oas import langchain_tools_to_openapi\n from toolguard.runtime import load_toolguards, load_toolguards_from_memory\n from toolguard.runtime.runtime import RESULTS_FILENAME\n\n from lfx.components.models_and_agents.policies.guard_sync_utils import sync_generated_guard_code_inputs\n from lfx.components.models_and_agents.policies.guarded_tool import GuardedTool\n from lfx.components.models_and_agents.policies.llm_wrapper import LangchainModelWrapper\n except ModuleNotFoundError as e:\n raise ImportError(_TOOLGUARD_INSTALL_HINT) from e\n return {\n \"PolicySpecOptions\": PolicySpecOptions,\n \"ToolGuardsCodeGenerationResult\": ToolGuardsCodeGenerationResult,\n \"generate_guard_specs\": generate_guard_specs,\n \"generate_guards_code\": generate_guards_code,\n \"langchain_tools_to_openapi\": langchain_tools_to_openapi,\n \"load_toolguards\": load_toolguards,\n \"load_toolguards_from_memory\": load_toolguards_from_memory,\n \"RESULTS_FILENAME\": RESULTS_FILENAME,\n \"sync_generated_guard_code_inputs\": sync_generated_guard_code_inputs,\n \"GuardedTool\": GuardedTool,\n \"LangchainModelWrapper\": LangchainModelWrapper,\n }\n\n @property\n def work_dir(self) -> Path:\n return TOOLGUARD_WORK_DIR / self._to_snake_case(self.project)\n\n def build_model(self) -> LanguageModel:\n llm_model = get_llm(\n model=self.model,\n user_id=self.user_id,\n api_key=self.api_key,\n stream=False,\n )\n if llm_model is None:\n msg = \"No language model selected. Please choose a model to proceed.\"\n raise ValueError(msg)\n return llm_model\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Dynamically update build config with user-filtered model options.\"\"\"\n updated_build_config = update_model_options_in_build_config(\n component=self,\n build_config=build_config,\n cache_key_prefix=\"language_model_options\",\n get_options_func=get_language_model_options,\n field_name=field_name,\n field_value=field_value,\n )\n tg = self._import_toolguard()\n py_module = self._to_snake_case(self.project)\n return tg[\"sync_generated_guard_code_inputs\"](\n build_config=updated_build_config,\n work_dir=self.work_dir,\n step2_subdir=STEP2,\n project_name=py_module,\n )\n\n async def _generate_guard_specs(self) -> list[ToolGuardSpec]:\n tg = self._import_toolguard()\n logger.debug(\"Starting step 1\")\n logger.debug(f\"model = {self.model}\")\n llm = tg[\"LangchainModelWrapper\"](self.build_model())\n out_dir = self.work_dir / STEP1\n if out_dir.exists():\n shutil.rmtree(out_dir)\n policy_text = \"\\n * \".join(self.policies)\n open_api = tg[\"langchain_tools_to_openapi\"](self.in_tools)\n\n options = tg[\"PolicySpecOptions\"](example_number=4)\n specs = await tg[\"generate_guard_specs\"](\n policy_text=policy_text, tools=open_api, llm=llm, work_dir=out_dir, options=options\n )\n logger.debug(\"Step 1 Done\")\n return specs\n\n async def _generate_guard_code(self, specs: list[ToolGuardSpec]) -> ToolGuardsCodeGenerationResult:\n tg = self._import_toolguard()\n logger.debug(\"Starting step 2\")\n out_dir = self.work_dir / STEP2\n if out_dir.exists():\n shutil.rmtree(out_dir)\n llm = tg[\"LangchainModelWrapper\"](self.build_model())\n app_name = self._to_snake_case(self.project)\n open_api = tg[\"langchain_tools_to_openapi\"](self.in_tools)\n\n gen_result = await tg[\"generate_guards_code\"](\n tools=open_api, tool_specs=specs, work_dir=out_dir, llm=llm, app_name=app_name\n )\n logger.debug(\"Step 2 Done\")\n return gen_result\n\n def in_recommended_models(self, model_name: str):\n return any(recommended in model_name for recommended in BUILDTIME_MODELS)\n\n def validate_before_generate(self) -> None:\n \"\"\"Validate required inputs before generating guard code.\"\"\"\n if not self.project:\n msg = \"Policies: project cannot be empty!\"\n raise ValueError(msg)\n\n if not any(self.policies):\n msg = \"Policies: policies cannot be empty!\"\n raise ValueError(msg)\n\n if not self.in_tools:\n msg = \"Policies: in_tools cannot be empty!\"\n raise ValueError(msg)\n\n if not self.model or not self.api_key:\n msg = \"Policies: model or api_key cannot be empty!\"\n raise ValueError(msg)\n\n # uncomment if willing to enforce certain models for buildtime\n # if not self.in_recommended_models(self.model[0][\"name\"]):\n # msg = f\"Policies: model {self.model[0]['name']} is not in recommended models: {BUILDTIME_MODELS}\"\n # raise ValueError(msg)\n\n async def generate(self):\n specs = await self._generate_guard_specs()\n res = await self._generate_guard_code(specs)\n\n # if there was a previous version of the guard, remove it from python cache\n unload_module(res.domain.app_name)\n\n def _verify_cached_guards(self, code_dir: Path) -> None:\n tg = self._import_toolguard()\n # Validate cache exists before attempting to load\n if not code_dir.exists():\n msg = (\n f\"Policies: Cache directory not found at '{code_dir}'. \"\n f\"Please run in 'Generate' mode first to create the guard code, \"\n f\"or verify the project name is correct.\"\n )\n raise ValueError(msg)\n\n try:\n tg[\"load_toolguards\"](code_dir)\n except FileNotFoundError as exc:\n msg = (\n f\"Policies: Required guard code files missing in '{code_dir}'. \"\n f\"Please run in 'Generate' mode to create the guard code.\"\n )\n raise ValueError(msg) from exc\n except Exception as exc:\n msg = (\n f\"Policies: Failed to load guard code from '{code_dir}'. \"\n f\"The cached code may be invalid or corrupted. \"\n f\"Try running in 'Generate' mode to rebuild the guard code. \"\n f\"Error: {exc!s}\"\n )\n raise ValueError(msg) from exc\n\n def _validate_before_using_cache(self, code_dir: Path) -> None:\n if not self.in_tools:\n msg = \"Policies: in_tools cannot be empty!\"\n raise ValueError(msg)\n\n self._verify_cached_guards(code_dir)\n\n def make_toolguard_result(self) -> ToolGuardsCodeGenerationResult:\n tg = self._import_toolguard()\n attrs = self.get_vertex().data[\"node\"][\"template\"]\n if not attrs:\n raise ValueError\n\n result_str = attrs[str(tg[\"RESULTS_FILENAME\"])][\"value\"]\n result = tg[\"ToolGuardsCodeGenerationResult\"].model_validate_json(result_str)\n\n result.domain.app_types.content = attrs.get(str(result.domain.app_types.file_name))[\"value\"]\n result.domain.app_api.content = attrs.get(str(result.domain.app_api.file_name))[\"value\"]\n result.domain.app_api_impl.content = attrs.get(str(result.domain.app_api_impl.file_name))[\"value\"]\n\n for tool in result.tools.values():\n tool.guard_file.content = attrs.get(str(tool.guard_file.file_name))[\"value\"]\n for tool_item in tool.item_guard_files:\n tool_item.content = attrs.get(str(tool_item.file_name))[\"value\"]\n\n return result\n\n async def guard_tools(self) -> list[Tool]:\n if self.enabled:\n tg = self._import_toolguard()\n mode = getattr(self, \"mode\", MODE_GENERATE)\n if mode == MODE_GENERATE:\n self.log(f\"Start generating guard code at {self.work_dir}\", name=\"info\")\n self.validate_before_generate()\n await self.generate()\n self.log(f\"Policies code generation saved to {self.work_dir}\", name=\"info\")\n self.log(\"Review the generated files in the details panel on the right.\", name=\"info\")\n\n else: # mode == \"guard\"\n self.log(f\"using cache from {self.work_dir}\", name=\"info\")\n code_dir = self.work_dir / STEP2\n self._validate_before_using_cache(code_dir)\n try:\n tg_result = self.make_toolguard_result()\n tg_runtime = tg[\"load_toolguards_from_memory\"](tg_result)\n guarded_tools = [tg[\"GuardedTool\"](tool, self.in_tools, tg_runtime) for tool in self.in_tools]\n return cast(\"list[Tool]\", guarded_tools)\n except Exception as e:\n logger.exception(e)\n raise\n\n return self.in_tools\n\n @staticmethod\n def _to_snake_case(human_name: str) -> str:\n \"\"\"Convert human-readable name to snake_case, sanitizing path traversal attempts.\"\"\"\n # Convert to lowercase\n result = human_name.lower()\n\n # Replace any non-alphanumeric character (including path traversal chars) with underscore\n result = re.sub(r\"[^a-z0-9]+\", \"_\", result)\n\n # Strip leading/trailing underscores\n result = result.strip(\"_\")\n\n # Ensure the result contains at least one alphanumeric character\n if not result or not re.search(r\"[a-z0-9]\", result):\n msg = \"Project name must contain at least one alphanumeric character\"\n raise ValueError(msg)\n\n return result\n" + "value": "from __future__ import annotations\n\nimport os\nimport re\nimport shutil\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, cast\n\nfrom lfx.base.models import LCModelComponent\nfrom lfx.base.models.unified_models import (\n get_language_model_options,\n get_llm,\n update_model_options_in_build_config,\n)\nfrom lfx.components.models_and_agents.policies.module_utils import unload_module\nfrom lfx.field_typing import LanguageModel, Tool\nfrom lfx.io import (\n BoolInput,\n HandleInput,\n ModelInput,\n MultilineInput,\n Output,\n SecretStrInput,\n StrInput,\n TabInput,\n)\nfrom lfx.log.logger import logger\n\nif TYPE_CHECKING:\n from toolguard.buildtime import ToolGuardsCodeGenerationResult, ToolGuardSpec\n\n from lfx.inputs.inputs import InputTypes\n\n\nTOOLGUARD_WORK_DIR = Path(os.getenv(\"TOOLGUARD_WORK_DIR\") or \"tmp_toolguard\")\nBUILDTIME_MODELS = [\"gpt-5\", \"claude-sonnet\"] # currently inactive, we recommend but do not enforce\nSTEP1 = \"Step_1\"\nSTEP2 = \"Step_2\"\nMODE_GENERATE = \"🛠️ Generate\"\nMODE_GUARD = \"🛡️ Guard\"\nGENERATED_GUARD_INFO_PREFIX = \"Auto-generated ToolGuard code for \"\n\n_TOOLGUARD_INSTALL_HINT = (\n \"The 'toolguard' package is required to use PoliciesComponent. \"\n \"Install the optional extra: `pip install 'langflow-base[toolguard]'`.\"\n)\n\n\nclass PoliciesComponent(LCModelComponent):\n \"\"\"Component for building tool protection code from textual business policies and instructions.\n\n This component uses ToolGuard to generate and apply policy-based guards to tools,\n ensuring that tool execution complies with defined business policies.\n Powered by ALTK ToolGuard (https://github.com/AgentToolkit/toolguard).\n\n `toolguard` is an optional extra (`langflow-base[toolguard]`); imports happen\n lazily inside methods so this component can be discovered and inspected even\n when the extra isn't installed.\n \"\"\"\n\n display_name = \"Policies\"\n description = \"\"\"Component for building tool protection code from textual business policies and instructions.\nPowered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )\"\"\"\n documentation: str = \"https://github.com/AgentToolkit/toolguard\"\n icon = \"shield-check\"\n name = \"policies\"\n beta = True\n\n inputs = cast(\n \"list[InputTypes]\",\n [\n BoolInput(\n name=\"enabled\",\n display_name=\"Enabled\",\n info=\"If `true` - guards tool calls. If `false`, skip policy validation.\",\n value=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Activity\",\n options=[MODE_GENERATE, MODE_GUARD],\n info=(\n \"Generate new guard code or apply existing guard. \"\n \"Review generated files in the details panel on the right.\"\n ),\n value=MODE_GENERATE,\n real_time_refresh=True,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"project\",\n display_name=\"Policies Project\",\n info=\"Folder name of the generated code\",\n value=\"my_project\",\n # required=True,\n ),\n HandleInput(\n name=\"in_tools\",\n display_name=\"Tools\",\n input_types=[\"Tool\"],\n is_list=True,\n required=True,\n info=\"These are the tools that the agent can use to help with tasks.\",\n ),\n StrInput(\n name=\"policies\",\n display_name=\"Policies\",\n info=\"One or more clear, well-defined and self-contained business policies\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Add business policy...\",\n list_add_label=\"Add Policy\",\n # input_types=[],\n ),\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=(\n \"Select LLM for Policies buildtime. We recommend using \"\n \"Anthropic Claude-Sonnet series for this task.\"\n ),\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Model Provider API key\",\n required=False,\n advanced=True,\n ),\n ],\n )\n outputs = [\n Output(\n display_name=\"Guarded Tools\",\n type_=Tool,\n name=\"guarded_tools\",\n method=\"guard_tools\",\n # group_outputs=True,\n ),\n ]\n\n @staticmethod\n def _import_toolguard():\n \"\"\"Lazily import `toolguard` and the sibling helpers that depend on it.\n\n Defined as a static method so it survives custom-component re-execution\n via `create_class`, which only re-executes the class body, not arbitrary\n module-level statements such as `try/except` import guards.\n \"\"\"\n try:\n from toolguard.buildtime import (\n PolicySpecOptions,\n ToolGuardsCodeGenerationResult,\n generate_guard_specs,\n generate_guards_code,\n )\n from toolguard.extra.langchain_to_oas import langchain_tools_to_openapi\n from toolguard.runtime import load_toolguards, load_toolguards_from_memory\n from toolguard.runtime.runtime import RESULTS_FILENAME\n\n from lfx.components.models_and_agents.policies.guard_sync_utils import sync_generated_guard_code_inputs\n from lfx.components.models_and_agents.policies.guarded_tool import GuardedTool\n from lfx.components.models_and_agents.policies.llm_wrapper import LangchainModelWrapper\n except ModuleNotFoundError as e:\n raise ImportError(_TOOLGUARD_INSTALL_HINT) from e\n return {\n \"PolicySpecOptions\": PolicySpecOptions,\n \"ToolGuardsCodeGenerationResult\": ToolGuardsCodeGenerationResult,\n \"generate_guard_specs\": generate_guard_specs,\n \"generate_guards_code\": generate_guards_code,\n \"langchain_tools_to_openapi\": langchain_tools_to_openapi,\n \"load_toolguards\": load_toolguards,\n \"load_toolguards_from_memory\": load_toolguards_from_memory,\n \"RESULTS_FILENAME\": RESULTS_FILENAME,\n \"sync_generated_guard_code_inputs\": sync_generated_guard_code_inputs,\n \"GuardedTool\": GuardedTool,\n \"LangchainModelWrapper\": LangchainModelWrapper,\n }\n\n @property\n def work_dir(self) -> Path:\n return TOOLGUARD_WORK_DIR / self._to_snake_case(self.project)\n\n def build_model(self) -> LanguageModel:\n llm_model = get_llm(\n model=self.model,\n user_id=self.user_id,\n api_key=self.api_key,\n stream=False,\n )\n if llm_model is None:\n msg = \"No language model selected. Please choose a model to proceed.\"\n raise ValueError(msg)\n return llm_model\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Dynamically update build config with user-filtered model options.\"\"\"\n updated_build_config = update_model_options_in_build_config(\n component=self,\n build_config=build_config,\n cache_key_prefix=\"language_model_options\",\n get_options_func=get_language_model_options,\n field_name=field_name,\n field_value=field_value,\n )\n tg = self._import_toolguard()\n py_module = self._to_snake_case(self.project)\n return tg[\"sync_generated_guard_code_inputs\"](\n build_config=updated_build_config,\n work_dir=self.work_dir,\n step2_subdir=STEP2,\n project_name=py_module,\n )\n\n async def _generate_guard_specs(self) -> list[ToolGuardSpec]:\n tg = self._import_toolguard()\n logger.debug(\"Starting step 1\")\n logger.debug(f\"model = {self.model}\")\n llm = tg[\"LangchainModelWrapper\"](self.build_model())\n out_dir = self.work_dir / STEP1\n if out_dir.exists():\n shutil.rmtree(out_dir)\n policy_text = \"\\n * \".join(self.policies)\n open_api = tg[\"langchain_tools_to_openapi\"](self.in_tools)\n\n options = tg[\"PolicySpecOptions\"](example_number=4)\n specs = await tg[\"generate_guard_specs\"](\n policy_text=policy_text, tools=open_api, llm=llm, work_dir=out_dir, options=options\n )\n logger.debug(\"Step 1 Done\")\n return specs\n\n async def _generate_guard_code(self, specs: list[ToolGuardSpec]) -> ToolGuardsCodeGenerationResult:\n tg = self._import_toolguard()\n logger.debug(\"Starting step 2\")\n out_dir = self.work_dir / STEP2\n if out_dir.exists():\n shutil.rmtree(out_dir)\n llm = tg[\"LangchainModelWrapper\"](self.build_model())\n app_name = self._to_snake_case(self.project)\n open_api = tg[\"langchain_tools_to_openapi\"](self.in_tools)\n\n gen_result = await tg[\"generate_guards_code\"](\n tools=open_api, tool_specs=specs, work_dir=out_dir, llm=llm, app_name=app_name\n )\n logger.debug(\"Step 2 Done\")\n return gen_result\n\n def in_recommended_models(self, model_name: str):\n return any(recommended in model_name for recommended in BUILDTIME_MODELS)\n\n def validate_before_generate(self) -> None:\n \"\"\"Validate required inputs before generating guard code.\"\"\"\n if not self.project:\n msg = \"Policies: project cannot be empty!\"\n raise ValueError(msg)\n\n if not any(self.policies):\n msg = \"Policies: policies cannot be empty!\"\n raise ValueError(msg)\n\n if not self.in_tools:\n msg = \"Policies: in_tools cannot be empty!\"\n raise ValueError(msg)\n\n # Only the model selection is mandatory. ``api_key`` is declared optional\n # (required=False, advanced=True) and is frequently supplied by the model\n # connection, an environment variable, or a global variable rather than by\n # this field — requiring it here wrongly blocked valid setups with\n # \"model or api_key cannot be empty!\". When credentials really are missing,\n # ``build_model`` -> ``get_llm`` raises a clear provider-specific error.\n if not self.model:\n msg = \"Policies: model cannot be empty!\"\n raise ValueError(msg)\n\n # uncomment if willing to enforce certain models for buildtime\n # if not self.in_recommended_models(self.model[0][\"name\"]):\n # msg = f\"Policies: model {self.model[0]['name']} is not in recommended models: {BUILDTIME_MODELS}\"\n # raise ValueError(msg)\n\n async def generate(self):\n specs = await self._generate_guard_specs()\n res = await self._generate_guard_code(specs)\n\n # if there was a previous version of the guard, remove it from python cache\n unload_module(res.domain.app_name)\n\n def _verify_cached_guards(self, code_dir: Path) -> None:\n tg = self._import_toolguard()\n # Validate cache exists before attempting to load\n if not code_dir.exists():\n msg = (\n f\"Policies: Cache directory not found at '{code_dir}'. \"\n f\"Please run in 'Generate' mode first to create the guard code, \"\n f\"or verify the project name is correct.\"\n )\n raise ValueError(msg)\n\n try:\n tg[\"load_toolguards\"](code_dir)\n except FileNotFoundError as exc:\n msg = (\n f\"Policies: Required guard code files missing in '{code_dir}'. \"\n f\"Please run in 'Generate' mode to create the guard code.\"\n )\n raise ValueError(msg) from exc\n except Exception as exc:\n msg = (\n f\"Policies: Failed to load guard code from '{code_dir}'. \"\n f\"The cached code may be invalid or corrupted. \"\n f\"Try running in 'Generate' mode to rebuild the guard code. \"\n f\"Error: {exc!s}\"\n )\n raise ValueError(msg) from exc\n\n def _validate_before_using_cache(self, code_dir: Path) -> None:\n if not self.in_tools:\n msg = \"Policies: in_tools cannot be empty!\"\n raise ValueError(msg)\n\n self._verify_cached_guards(code_dir)\n\n @staticmethod\n def _template_field_key(file_name: str | Path) -> str:\n r\"\"\"Normalize a generated file name to its node-template field key.\n\n ``sync_generated_guard_code_inputs`` keys every generated CodeInput by the\n file's POSIX relative path (``Path.relative_to(...).as_posix()``), so the\n keys always use forward slashes. The toolguard result model stores\n ``file_name`` as a :class:`pathlib.Path`, whose ``str()`` uses the OS\n separator — backslashes on Windows. Reading back with ``str(file_name)``\n therefore misses every key on Windows, ``attrs.get(...)`` returns ``None``\n and the subsequent ``[\"value\"]`` raised the cryptic\n ``'NoneType' object is not subscriptable`` (issue #13727).\n\n Normalizing through ``as_posix()`` is the exact inverse of how the keys are\n written, so lookups match on every platform. ``replace(\"\\\\\", \"/\")`` is a\n belt-and-suspenders guard for the rare case where ``file_name`` is already a\n string carrying Windows separators (e.g. a flow generated on Windows and\n opened on POSIX, where ``PurePosixPath`` would not split on backslashes).\n \"\"\"\n return Path(str(file_name).replace(\"\\\\\", \"/\")).as_posix()\n\n def make_toolguard_result(self) -> ToolGuardsCodeGenerationResult:\n tg = self._import_toolguard()\n attrs = self.get_vertex().data[\"node\"][\"template\"]\n if not attrs:\n msg = \"Policies: component template data is missing. This may indicate a corrupted flow state.\"\n raise ValueError(msg)\n\n def read_content(file_name: str | Path) -> str:\n \"\"\"Fetch a generated file's stored source from the node template.\n\n Raises a clear, actionable error when the field is absent instead of\n letting a missing key surface as ``'NoneType' object is not\n subscriptable``. A missing field means the guard code was never\n generated (or only the ``pass # FIXME`` scaffold was produced), so the\n fix is to re-run Generate.\n \"\"\"\n key = self._template_field_key(file_name)\n field = attrs.get(key)\n if field is None:\n msg = (\n f\"Policies: generated guard file '{key}' is missing from the component. \"\n f\"Re-run in 'Generate' mode to (re)build the guard code before guarding.\"\n )\n raise ValueError(msg)\n return field[\"value\"]\n\n result_str = read_content(tg[\"RESULTS_FILENAME\"])\n result = tg[\"ToolGuardsCodeGenerationResult\"].model_validate_json(result_str)\n\n result.domain.app_types.content = read_content(result.domain.app_types.file_name)\n result.domain.app_api.content = read_content(result.domain.app_api.file_name)\n result.domain.app_api_impl.content = read_content(result.domain.app_api_impl.file_name)\n\n for tool in result.tools.values():\n tool.guard_file.content = read_content(tool.guard_file.file_name)\n for tool_item in tool.item_guard_files:\n tool_item.content = read_content(tool_item.file_name)\n\n return result\n\n @staticmethod\n def _code_execution_allowed() -> bool:\n \"\"\"Whether executing guard code is permitted by the deployment policy.\n\n ToolGuard runs guard Python whose source comes from the component's\n client-editable CodeInput template values (make_toolguard_result reads\n attrs[...][\"value\"]) — these are NOT covered by the custom-component hash\n gate. So when an operator locks the deployment down with\n allow_custom_components=False, running that code must be refused too.\n\n Fails closed to match validate_flow_for_current_settings: when the\n settings layer is present but the service is unavailable (returns None),\n execution is denied. Fail-open is reserved for the truly standalone case\n where the settings layer cannot be imported at all (lfx used as a bare\n library), which is a local/trusted context.\n \"\"\"\n try:\n from lfx.services.deps import get_settings_service\n except ImportError:\n # No settings layer at all (lfx used as a bare library) -> local/trusted.\n return True\n\n settings_service = get_settings_service()\n if settings_service is None:\n # Settings layer present but service unavailable: fail closed, matching\n # validate_flow_for_current_settings (which raises in this case).\n return False\n return bool(getattr(settings_service.settings, \"allow_custom_components\", True))\n\n async def guard_tools(self) -> list[Tool]:\n if self.enabled:\n # Refuse to execute guard code when allow_custom_components is disabled.\n # Checked before importing/loading any toolguard runtime so\n # the client-supplied CodeInput guard values are never exec'd.\n if not self._code_execution_allowed():\n msg = (\n \"Policies/ToolGuard executes guard code, which is disabled because \"\n \"allow_custom_components is False. Set LANGFLOW_ALLOW_CUSTOM_COMPONENTS=true \"\n \"to enable this component.\"\n )\n raise ValueError(msg)\n tg = self._import_toolguard()\n mode = getattr(self, \"mode\", MODE_GENERATE)\n if mode == MODE_GENERATE:\n self.log(f\"Start generating guard code at {self.work_dir}\", name=\"info\")\n self.validate_before_generate()\n await self.generate()\n self.log(f\"Policies code generation saved to {self.work_dir}\", name=\"info\")\n self.log(\"Review the generated files in the details panel on the right.\", name=\"info\")\n\n else: # mode == \"guard\"\n self.log(f\"using cache from {self.work_dir}\", name=\"info\")\n code_dir = self.work_dir / STEP2\n self._validate_before_using_cache(code_dir)\n try:\n tg_result = self.make_toolguard_result()\n tg_runtime = tg[\"load_toolguards_from_memory\"](tg_result)\n guarded_tools = [tg[\"GuardedTool\"](tool, self.in_tools, tg_runtime) for tool in self.in_tools]\n return cast(\"list[Tool]\", guarded_tools)\n except Exception as e:\n logger.exception(e)\n raise\n\n return self.in_tools\n\n @staticmethod\n def _to_snake_case(human_name: str) -> str:\n \"\"\"Convert human-readable name to snake_case, sanitizing path traversal attempts.\"\"\"\n # Convert to lowercase\n result = human_name.lower()\n\n # Replace any non-alphanumeric character (including path traversal chars) with underscore\n result = re.sub(r\"[^a-z0-9]+\", \"_\", result)\n\n # Strip leading/trailing underscores\n result = result.strip(\"_\")\n\n # Ensure the result contains at least one alphanumeric character\n if not result or not re.search(r\"[a-z0-9]\", result):\n msg = \"Project name must contain at least one alphanumeric character\"\n raise ValueError(msg)\n\n return result\n" }, "enabled": { "_input_type": "BoolInput", @@ -28230,7 +28236,7 @@ "icon": "Python", "legacy": true, "metadata": { - "code_hash": "b15f1d388e03", + "code_hash": "8e9f47f2b23f", "dependencies": { "dependencies": [ { @@ -28308,7 +28314,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import importlib\n\nfrom langchain_core.tools import StructuredTool, ToolException\nfrom langchain_experimental.utilities import PythonREPL\nfrom pydantic import BaseModel, Field\n\nfrom lfx.base.langchain_utilities.model import LCToolComponent\nfrom lfx.field_typing import Tool\nfrom lfx.inputs.inputs import StrInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.utils.python_repl_security import safe_builtins, validate_code_safety\n\n\nclass PythonREPLToolComponent(LCToolComponent):\n display_name = \"Python REPL\"\n description = \"A tool for running Python code in a REPL environment.\"\n name = \"PythonREPLTool\"\n icon = \"Python\"\n legacy = True\n replacement = [\"processing.PythonREPLComponent\"]\n\n inputs = [\n StrInput(\n name=\"name\",\n display_name=\"Tool Name\",\n info=\"The name of the tool.\",\n value=\"python_repl\",\n ),\n StrInput(\n name=\"description\",\n display_name=\"Tool Description\",\n info=\"A description of the tool.\",\n value=\"A Python shell. Use this to execute python commands. \"\n \"Input should be a valid python command. \"\n \"If you want to see the output of a value, you should print it out with `print(...)`.\",\n ),\n StrInput(\n name=\"global_imports\",\n display_name=\"Global Imports\",\n info=\"A comma-separated list of modules to import globally, e.g. 'math,numpy'.\",\n value=\"math\",\n ),\n StrInput(\n name=\"code\",\n display_name=\"Python Code\",\n info=\"The Python code to execute.\",\n value=\"print('Hello, World!')\",\n ),\n ]\n\n class PythonREPLSchema(BaseModel):\n code: str = Field(..., description=\"The Python code to execute.\")\n\n def get_globals(self, global_imports: str | list[str]) -> dict:\n global_dict = {}\n if isinstance(global_imports, str):\n modules = [module.strip() for module in global_imports.split(\",\")]\n elif isinstance(global_imports, list):\n modules = global_imports\n else:\n msg = \"global_imports must be either a string or a list\"\n raise TypeError(msg)\n\n for module in modules:\n try:\n imported_module = importlib.import_module(module)\n global_dict[imported_module.__name__] = imported_module\n except ImportError as e:\n msg = f\"Could not import module {module}\"\n raise ImportError(msg) from e\n # Restrict builtins so the import allow-list cannot be silently bypassed\n # (e.g. __import__(\"subprocess\")). Without this, exec() auto-injects the full\n # builtins module, leaving __import__/open/eval/exec reachable.\n global_dict[\"__builtins__\"] = safe_builtins()\n return global_dict\n\n def build_tool(self) -> Tool:\n def run_python_code(code: str) -> str:\n try:\n # Validate the exact (sanitized) code that will run, rejecting inline\n # imports and escape gadgets; combined with the restricted builtins in\n # get_globals(). A fresh globals namespace is built per invocation so\n # state does not leak across tool calls.\n cleaned_code = PythonREPL.sanitize_input(code)\n validate_code_safety(cleaned_code)\n python_repl = PythonREPL(_globals=self.get_globals(self.global_imports))\n return python_repl.run(cleaned_code)\n except Exception as e:\n logger.debug(\"Error running Python code\", exc_info=True)\n raise ToolException(str(e)) from e\n\n tool = StructuredTool.from_function(\n name=self.name,\n description=self.description,\n func=run_python_code,\n args_schema=self.PythonREPLSchema,\n )\n\n self.status = f\"Python REPL Tool created with global imports: {self.global_imports}\"\n return tool\n\n def run_model(self) -> list[Data]:\n tool = self.build_tool()\n result = tool.run(self.code)\n return [Data(data={\"result\": result})]\n" + "value": "import importlib\n\nfrom langchain_core.tools import StructuredTool, ToolException\nfrom langchain_experimental.utilities import PythonREPL\nfrom pydantic import BaseModel, Field\n\nfrom lfx.base.langchain_utilities.model import LCToolComponent\nfrom lfx.field_typing import Tool\nfrom lfx.inputs.inputs import StrInput\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.utils.python_repl_security import ensure_code_execution_enabled, safe_builtins, validate_code_safety\n\n\nclass PythonREPLToolComponent(LCToolComponent):\n display_name = \"Python REPL\"\n description = \"A tool for running Python code in a REPL environment.\"\n name = \"PythonREPLTool\"\n icon = \"Python\"\n legacy = True\n replacement = [\"processing.PythonREPLComponent\"]\n\n inputs = [\n StrInput(\n name=\"name\",\n display_name=\"Tool Name\",\n info=\"The name of the tool.\",\n value=\"python_repl\",\n ),\n StrInput(\n name=\"description\",\n display_name=\"Tool Description\",\n info=\"A description of the tool.\",\n value=\"A Python shell. Use this to execute python commands. \"\n \"Input should be a valid python command. \"\n \"If you want to see the output of a value, you should print it out with `print(...)`.\",\n ),\n StrInput(\n name=\"global_imports\",\n display_name=\"Global Imports\",\n info=\"A comma-separated list of modules to import globally, e.g. 'math,numpy'.\",\n value=\"math\",\n ),\n StrInput(\n name=\"code\",\n display_name=\"Python Code\",\n info=\"The Python code to execute.\",\n value=\"print('Hello, World!')\",\n ),\n ]\n\n class PythonREPLSchema(BaseModel):\n code: str = Field(..., description=\"The Python code to execute.\")\n\n def get_globals(self, global_imports: str | list[str]) -> dict:\n global_dict = {}\n if isinstance(global_imports, str):\n modules = [module.strip() for module in global_imports.split(\",\")]\n elif isinstance(global_imports, list):\n modules = global_imports\n else:\n msg = \"global_imports must be either a string or a list\"\n raise TypeError(msg)\n\n for module in modules:\n try:\n imported_module = importlib.import_module(module)\n global_dict[imported_module.__name__] = imported_module\n except ImportError as e:\n msg = f\"Could not import module {module}\"\n raise ImportError(msg) from e\n # Restrict builtins so the import allow-list cannot be silently bypassed\n # (e.g. __import__(\"subprocess\")). Without this, exec() auto-injects the full\n # builtins module, leaving __import__/open/eval/exec reachable.\n global_dict[\"__builtins__\"] = safe_builtins()\n return global_dict\n\n def build_tool(self) -> Tool:\n def run_python_code(code: str) -> str:\n try:\n # Refuse to run user code when allow_custom_components is disabled\n # (GHSA-8qpj-27x8-pwpq).\n ensure_code_execution_enabled()\n # Validate the exact (sanitized) code that will run, rejecting inline\n # imports and escape gadgets; combined with the restricted builtins in\n # get_globals(). A fresh globals namespace is built per invocation so\n # state does not leak across tool calls.\n cleaned_code = PythonREPL.sanitize_input(code)\n validate_code_safety(cleaned_code)\n python_repl = PythonREPL(_globals=self.get_globals(self.global_imports))\n return python_repl.run(cleaned_code)\n except Exception as e:\n logger.debug(\"Error running Python code\", exc_info=True)\n raise ToolException(str(e)) from e\n\n tool = StructuredTool.from_function(\n name=self.name,\n description=self.description,\n func=run_python_code,\n args_schema=self.PythonREPLSchema,\n )\n\n self.status = f\"Python REPL Tool created with global imports: {self.global_imports}\"\n return tool\n\n def run_model(self) -> list[Data]:\n tool = self.build_tool()\n result = tool.run(self.code)\n return [Data(data={\"result\": result})]\n" }, "description": { "_input_type": "StrInput", @@ -30281,7 +30287,7 @@ "icon": "square-terminal", "legacy": false, "metadata": { - "code_hash": "b92cf9819227", + "code_hash": "d5130284a8c6", "dependencies": { "dependencies": [ { @@ -30334,7 +30340,7 @@ "show": true, "title_case": false, "type": "code", - "value": "import importlib\n\nfrom langchain_experimental.utilities import PythonREPL\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import MultilineInput, Output, StrInput\nfrom lfx.schema.data import Data\nfrom lfx.utils.python_repl_security import safe_builtins, validate_code_safety\n\n\nclass PythonREPLComponent(Component):\n display_name = \"Python Interpreter\"\n description = \"Run Python code with optional imports. Use print() to see the output.\"\n documentation: str = \"https://docs.langflow.org/python-interpreter\"\n icon = \"square-terminal\"\n\n inputs = [\n StrInput(\n name=\"global_imports\",\n display_name=\"Global Imports\",\n info=\"A comma-separated list of modules to import globally, e.g. 'math,numpy,pandas'.\",\n # Default kept minimal: powerful modules (e.g. pandas, whose read_pickle/eval\n # are code-execution sinks) must be opted into explicitly via this field.\n value=\"math\",\n required=True,\n ),\n MultilineInput(\n name=\"python_code\",\n display_name=\"Python Code\",\n info=\"The Python code to execute. Only modules specified in Global Imports can be used.\",\n value=\"print('Hello, World!')\",\n input_types=[\"Message\"],\n tool_mode=True,\n required=True,\n ),\n ]\n\n outputs = [\n Output(\n display_name=\"Results\",\n name=\"results\",\n type_=Data,\n method=\"run_python_repl\",\n ),\n ]\n\n def get_globals(self, global_imports: str | list[str]) -> dict:\n \"\"\"Create a globals dictionary with only the specified allowed imports.\"\"\"\n global_dict = {}\n\n try:\n if isinstance(global_imports, str):\n modules = [module.strip() for module in global_imports.split(\",\")]\n elif isinstance(global_imports, list):\n modules = global_imports\n else:\n msg = \"global_imports must be either a string or a list\"\n raise TypeError(msg)\n\n for module in modules:\n try:\n imported_module = importlib.import_module(module)\n global_dict[imported_module.__name__] = imported_module\n except ImportError as e:\n msg = f\"Could not import module {module}: {e!s}\"\n raise ImportError(msg) from e\n\n except Exception as e:\n self.log(f\"Error in global imports: {e!s}\")\n raise\n else:\n self.log(f\"Successfully imported modules: {list(global_dict.keys())}\")\n # Restrict builtins so the import allow-list cannot be silently bypassed\n # (e.g. __import__(\"subprocess\")). Without this, exec() auto-injects the full\n # builtins module, leaving __import__/open/eval/exec reachable.\n global_dict[\"__builtins__\"] = safe_builtins()\n return global_dict\n\n def run_python_repl(self) -> Data:\n try:\n # Validate the exact code that will run: PythonREPL.run() strips a leading\n # \"python\"/backticks/whitespace prefix before exec, so validate the sanitized\n # form. Rejects inline imports and escape gadgets (e.g.\n # ().__class__.__subclasses__()); combined with restricted builtins in get_globals().\n code = PythonREPL.sanitize_input(self.python_code)\n validate_code_safety(code)\n globals_ = self.get_globals(self.global_imports)\n python_repl = PythonREPL(_globals=globals_)\n result = python_repl.run(code)\n result = result.strip() if result else \"\"\n\n self.log(\"Code execution completed successfully\")\n return Data(data={\"result\": result})\n\n except ImportError as e:\n error_message = f\"Import Error: {e!s}\"\n self.log(error_message)\n return Data(data={\"error\": error_message})\n\n except SyntaxError as e:\n error_message = f\"Syntax Error: {e!s}\"\n self.log(error_message)\n return Data(data={\"error\": error_message})\n\n except (NameError, TypeError, ValueError) as e:\n error_message = f\"Error during execution: {e!s}\"\n self.log(error_message)\n return Data(data={\"error\": error_message})\n\n def build(self):\n return self.run_python_repl\n" + "value": "import importlib\n\nfrom langchain_experimental.utilities import PythonREPL\n\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.io import MultilineInput, Output, StrInput\nfrom lfx.schema.data import Data\nfrom lfx.utils.python_repl_security import ensure_code_execution_enabled, safe_builtins, validate_code_safety\n\n\nclass PythonREPLComponent(Component):\n display_name = \"Python Interpreter\"\n description = \"Run Python code with optional imports. Use print() to see the output.\"\n documentation: str = \"https://docs.langflow.org/python-interpreter\"\n icon = \"square-terminal\"\n\n inputs = [\n StrInput(\n name=\"global_imports\",\n display_name=\"Global Imports\",\n info=\"A comma-separated list of modules to import globally, e.g. 'math,numpy,pandas'.\",\n # Default kept minimal: powerful modules (e.g. pandas, whose read_pickle/eval\n # are code-execution sinks) must be opted into explicitly via this field.\n value=\"math\",\n required=True,\n ),\n MultilineInput(\n name=\"python_code\",\n display_name=\"Python Code\",\n info=\"The Python code to execute. Only modules specified in Global Imports can be used.\",\n value=\"print('Hello, World!')\",\n input_types=[\"Message\"],\n tool_mode=True,\n required=True,\n ),\n ]\n\n outputs = [\n Output(\n display_name=\"Results\",\n name=\"results\",\n type_=Data,\n method=\"run_python_repl\",\n ),\n ]\n\n def get_globals(self, global_imports: str | list[str]) -> dict:\n \"\"\"Create a globals dictionary with only the specified allowed imports.\"\"\"\n global_dict = {}\n\n try:\n if isinstance(global_imports, str):\n modules = [module.strip() for module in global_imports.split(\",\")]\n elif isinstance(global_imports, list):\n modules = global_imports\n else:\n msg = \"global_imports must be either a string or a list\"\n raise TypeError(msg)\n\n for module in modules:\n try:\n imported_module = importlib.import_module(module)\n global_dict[imported_module.__name__] = imported_module\n except ImportError as e:\n msg = f\"Could not import module {module}: {e!s}\"\n raise ImportError(msg) from e\n\n except Exception as e:\n self.log(f\"Error in global imports: {e!s}\")\n raise\n else:\n self.log(f\"Successfully imported modules: {list(global_dict.keys())}\")\n # Restrict builtins so the import allow-list cannot be silently bypassed\n # (e.g. __import__(\"subprocess\")). Without this, exec() auto-injects the full\n # builtins module, leaving __import__/open/eval/exec reachable.\n global_dict[\"__builtins__\"] = safe_builtins()\n return global_dict\n\n def run_python_repl(self) -> Data:\n try:\n # Refuse to run user code when allow_custom_components is disabled\n # (GHSA-8qpj-27x8-pwpq). Raised before any sanitize/exec.\n ensure_code_execution_enabled()\n # Validate the exact code that will run: PythonREPL.run() strips a leading\n # \"python\"/backticks/whitespace prefix before exec, so validate the sanitized\n # form. Rejects inline imports and escape gadgets (e.g.\n # ().__class__.__subclasses__()); combined with restricted builtins in get_globals().\n code = PythonREPL.sanitize_input(self.python_code)\n validate_code_safety(code)\n globals_ = self.get_globals(self.global_imports)\n python_repl = PythonREPL(_globals=globals_)\n result = python_repl.run(code)\n result = result.strip() if result else \"\"\n\n self.log(\"Code execution completed successfully\")\n return Data(data={\"result\": result})\n\n except ImportError as e:\n error_message = f\"Import Error: {e!s}\"\n self.log(error_message)\n return Data(data={\"error\": error_message})\n\n except SyntaxError as e:\n error_message = f\"Syntax Error: {e!s}\"\n self.log(error_message)\n return Data(data={\"error\": error_message})\n\n except (NameError, TypeError, ValueError) as e:\n error_message = f\"Error during execution: {e!s}\"\n self.log(error_message)\n return Data(data={\"error\": error_message})\n\n def build(self):\n return self.run_python_repl\n" }, "global_imports": { "_input_type": "StrInput", @@ -30396,6 +30402,6 @@ "num_components": 129, "num_modules": 17 }, - "sha256": "5e1f63e184ecaf086d734fc304323c14f50ac9828b0d8f7e5f56a602739fab24", + "sha256": "1ade2bd84f2e12e4d34c695dc0db62041c7f8b74166390f6f57696d4eb66d793", "version": "1.11.0" }