diff --git a/.secrets.baseline b/.secrets.baseline
index 8c0c458bf5d8..15ae7ddfef2a 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -3565,7 +3565,7 @@
"filename": "src/backend/tests/unit/components/languagemodels/test_xai.py",
"hashed_secret": "3bee216ebc256d692260fc3adc765050508fef5e",
"is_verified": false,
- "line_number": 150,
+ "line_number": 159,
"is_secret": false
}
],
@@ -9287,5 +9287,5 @@
}
]
},
- "generated_at": "2026-06-10T08:36:23Z"
+ "generated_at": "2026-06-12T22:46:30Z"
}
diff --git a/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json b/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json
index ac1c998158ff..7bb0a0e1cfee 100644
--- a/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json
+++ b/src/backend/base/langflow/initial_setup/starter_projects/Structured Data Analysis Agent.json
@@ -4076,7 +4076,7 @@
"legacy": false,
"lf_version": "1.8.1",
"metadata": {
- "code_hash": "2aa7e6ecf48c",
+ "code_hash": "0f49f2dd2972",
"dependencies": {
"dependencies": [
{
@@ -4223,7 +4223,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import asyncio\nimport json\nfrom contextlib import suppress\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import ChatOllama\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.base_model import build_model_from_schema\nfrom lfx.io import (\n BoolInput,\n DictInput,\n DropdownInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n TableInput,\n)\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.table import EditMode\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\nTABLE_ROW_PLACEHOLDER = {\"name\": \"field\", \"description\": \"description of field\", \"type\": \"str\", \"multiple\": \"False\"}\n\n\nclass ChatOllamaComponent(LCModelComponent):\n display_name = \"Ollama\"\n description = \"Generate text using Ollama Local LLMs.\"\n icon = \"Ollama\"\n name = \"OllamaModel\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n DESIRED_CAPABILITY = \"completion\"\n TOOL_CALLING_CAPABILITY = \"tools\"\n\n # Define the table schema for the format input\n TABLE_SCHEMA = [\n {\n \"name\": \"name\",\n \"display_name\": \"Name\",\n \"type\": \"str\",\n \"description\": \"Specify the name of the output field.\",\n \"default\": \"field\",\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"description\",\n \"display_name\": \"Description\",\n \"type\": \"str\",\n \"description\": \"Describe the purpose of the output field.\",\n \"default\": \"description of field\",\n \"edit_mode\": EditMode.POPOVER,\n },\n {\n \"name\": \"type\",\n \"display_name\": \"Type\",\n \"type\": \"str\",\n \"edit_mode\": EditMode.INLINE,\n \"description\": (\"Indicate the data type of the output field (e.g., str, int, float, bool, dict).\"),\n \"options\": [\"str\", \"int\", \"float\", \"bool\", \"dict\"],\n \"default\": \"str\",\n },\n {\n \"name\": \"multiple\",\n \"display_name\": \"As List\",\n \"type\": \"boolean\",\n \"description\": \"Set to True if this output field should be a list of the specified type.\",\n \"edit_mode\": EditMode.INLINE,\n \"options\": [\"True\", \"False\"],\n \"default\": \"False\",\n },\n ]\n default_table_row = {row[\"name\"]: row.get(\"default\", None) for row in TABLE_SCHEMA}\n default_table_row_schema = build_model_from_schema([default_table_row]).model_json_schema()\n\n inputs = [\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama API URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n options=[],\n info=\"Refer to https://ollama.com/library for more models.\",\n refresh_button=True,\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n TableInput(\n name=\"format\",\n display_name=\"Format\",\n info=\"Specify the format of the output.\",\n table_schema=TABLE_SCHEMA,\n value=default_table_row,\n show=False,\n ),\n DictInput(name=\"metadata\", display_name=\"Metadata\", info=\"Metadata to add to the run trace.\", advanced=True),\n DropdownInput(\n name=\"mirostat\",\n display_name=\"Mirostat\",\n options=[\"Disabled\", \"Mirostat\", \"Mirostat 2.0\"],\n info=\"Enable/disable Mirostat sampling for controlling perplexity.\",\n value=\"Disabled\",\n advanced=True,\n real_time_refresh=True,\n ),\n FloatInput(\n name=\"mirostat_eta\",\n display_name=\"Mirostat Eta\",\n info=\"Learning rate for Mirostat algorithm. (Default: 0.1)\",\n advanced=True,\n ),\n FloatInput(\n name=\"mirostat_tau\",\n display_name=\"Mirostat Tau\",\n info=\"Controls the balance between coherence and diversity of the output. (Default: 5.0)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_ctx\",\n display_name=\"Context Window Size\",\n info=\"Size of the context window for generating tokens. (Default: 2048)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_gpu\",\n display_name=\"Number of GPUs\",\n info=\"Number of GPUs to use for computation. (Default: 1 on macOS, 0 to disable)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_thread\",\n display_name=\"Number of Threads\",\n info=\"Number of threads to use during computation. (Default: detected for optimal performance)\",\n advanced=True,\n ),\n IntInput(\n name=\"repeat_last_n\",\n display_name=\"Repeat Last N\",\n info=\"How far back the model looks to prevent repetition. (Default: 64, 0 = disabled, -1 = num_ctx)\",\n advanced=True,\n ),\n FloatInput(\n name=\"repeat_penalty\",\n display_name=\"Repeat Penalty\",\n info=\"Penalty for repetitions in generated text. (Default: 1.1)\",\n advanced=True,\n ),\n FloatInput(name=\"tfs_z\", display_name=\"TFS Z\", info=\"Tail free sampling value. (Default: 1)\", advanced=True),\n IntInput(name=\"timeout\", display_name=\"Timeout\", info=\"Timeout for the request stream.\", advanced=True),\n IntInput(\n name=\"top_k\", display_name=\"Top K\", info=\"Limits token selection to top K. (Default: 40)\", advanced=True\n ),\n FloatInput(name=\"top_p\", display_name=\"Top P\", info=\"Works together with top-k. (Default: 0.9)\", advanced=True),\n BoolInput(\n name=\"enable_verbose_output\",\n display_name=\"Ollama Verbose Output\",\n info=\"Whether to print out response text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"tags\",\n display_name=\"Tags\",\n info=\"Comma-separated list of tags to add to the run trace.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"stop_tokens\",\n display_name=\"Stop Tokens\",\n info=\"Comma-separated list of tokens to signal the model to stop generating text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"system\", display_name=\"System\", info=\"System to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"tool_model_enabled\",\n display_name=\"Tool Model Enabled\",\n info=\"Whether to enable tool calling in the model.\",\n value=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"template\", display_name=\"Template\", info=\"Template to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"enable_structured_output\",\n display_name=\"Enable Structured Output\",\n info=\"Whether to enable structured output in the model.\",\n value=False,\n advanced=False,\n real_time_refresh=True,\n ),\n *LCModelComponent.get_base_inputs(),\n ]\n\n outputs = [\n Output(display_name=\"Text\", name=\"text_output\", method=\"text_response\"),\n Output(display_name=\"Language Model\", name=\"model_output\", method=\"build_model\"),\n Output(display_name=\"JSON\", name=\"data_output\", method=\"build_data_output\"),\n Output(display_name=\"Table\", name=\"dataframe_output\", method=\"build_dataframe_output\"),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n # Mapping mirostat settings to their corresponding values\n mirostat_options = {\"Mirostat\": 1, \"Mirostat 2.0\": 2}\n\n # Default to None for 'Disabled'\n mirostat_value = mirostat_options.get(self.mirostat, None)\n\n # Set mirostat_eta and mirostat_tau to None if mirostat is disabled\n if mirostat_value is None:\n mirostat_eta = None\n mirostat_tau = None\n else:\n mirostat_eta = self.mirostat_eta\n mirostat_tau = self.mirostat_tau\n\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Check if URL contains /v1 suffix (OpenAI-compatible mode)\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n # Strip /v1 suffix and log warning\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n try:\n output_format = self._parse_format_field(self.format) if self.enable_structured_output else None\n except Exception as e:\n msg = f\"Failed to parse the format field: {e}\"\n raise ValueError(msg) from e\n\n # Mapping system settings to their corresponding values\n llm_params = {\n \"base_url\": transformed_base_url,\n \"model\": self.model_name,\n \"mirostat\": mirostat_value,\n \"format\": output_format or None,\n \"metadata\": self.metadata,\n \"tags\": self.tags.split(\",\") if self.tags else None,\n \"mirostat_eta\": mirostat_eta,\n \"mirostat_tau\": mirostat_tau,\n \"num_ctx\": self.num_ctx or None,\n \"num_gpu\": self.num_gpu or None,\n \"num_thread\": self.num_thread or None,\n \"repeat_last_n\": self.repeat_last_n or None,\n \"repeat_penalty\": self.repeat_penalty or None,\n \"temperature\": self.temperature or None,\n \"stop\": self.stop_tokens.split(\",\") if self.stop_tokens else None,\n \"system\": self.system,\n \"tfs_z\": self.tfs_z or None,\n \"timeout\": self.timeout or None,\n \"top_k\": self.top_k or None,\n \"top_p\": self.top_p or None,\n \"verbose\": self.enable_verbose_output or False,\n \"template\": self.template,\n }\n headers = self.headers\n if headers is not None:\n llm_params[\"client_kwargs\"] = {\"headers\": headers}\n\n # Remove parameters with None values\n llm_params = {k: v for k, v in llm_params.items() if v is not None}\n\n try:\n output = ChatOllama(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n\n return output\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n async with httpx.AsyncClient() as client:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n return (\n await client.get(url=urljoin(url, \"api/tags\"), headers=self.headers)\n ).status_code == HTTP_STATUS_OK\n except httpx.RequestError:\n return False\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name == \"enable_structured_output\": # bind enable_structured_output boolean to format show value\n build_config[\"format\"][\"show\"] = field_value\n\n if field_name == \"mirostat\":\n if field_value == \"Disabled\":\n build_config[\"mirostat_eta\"][\"advanced\"] = True\n build_config[\"mirostat_tau\"][\"advanced\"] = True\n build_config[\"mirostat_eta\"][\"value\"] = None\n build_config[\"mirostat_tau\"][\"value\"] = None\n\n else:\n build_config[\"mirostat_eta\"][\"advanced\"] = False\n build_config[\"mirostat_tau\"][\"advanced\"] = False\n\n if field_value == \"Mirostat 2.0\":\n build_config[\"mirostat_eta\"][\"value\"] = 0.2\n build_config[\"mirostat_tau\"][\"value\"] = 10\n else:\n build_config[\"mirostat_eta\"][\"value\"] = 0.1\n build_config[\"mirostat_tau\"][\"value\"] = 5\n\n if field_name in {\"model_name\", \"base_url\", \"tool_model_enabled\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n tool_model_enabled = build_config[\"tool_model_enabled\"].get(\"value\", False) or self.tool_model_enabled\n build_config[\"model_name\"][\"options\"] = await self.get_models(\n base_url_to_check, tool_model_enabled=tool_model_enabled\n )\n else:\n build_config[\"model_name\"][\"options\"] = []\n if field_name == \"keep_alive_flag\":\n if field_value == \"Keep\":\n build_config[\"keep_alive\"][\"value\"] = \"-1\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n elif field_value == \"Immediately\":\n build_config[\"keep_alive\"][\"value\"] = \"0\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n else:\n build_config[\"keep_alive\"][\"advanced\"] = False\n\n return build_config\n\n async def get_models(self, base_url_value: str, *, tool_model_enabled: bool | None = None) -> list[str]:\n \"\"\"Fetches a list of models from the Ollama API suitable for text generation.\n\n Args:\n base_url_value (str): The base URL of the Ollama API.\n tool_model_enabled (bool | None, optional): If True, filters the models further to include\n only those that support tool calling. Defaults to None.\n\n Returns:\n list[str]: A list of model names suitable for text generation. Models are included if:\n - They have the \"completion\" capability, OR\n - The capabilities field is not returned (backwards compatibility with older Ollama versions)\n If `tool_model_enabled` is True, only models with verified \"tools\" capability are included\n (models without capabilities info are excluded in this case).\n\n Raises:\n ValueError: If there is an issue with the API request or response, or if the model\n names cannot be retrieved.\n \"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n async with httpx.AsyncClient() as client:\n headers = self.headers\n # Fetch available models\n tags_response = await client.get(url=tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are NOT embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await client.post(url=show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY)\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n # If capabilities not provided, assume it's a completion model (backwards compatibility\n # with older Ollama versions that don't return capabilities from /api/show)\n if capabilities is None:\n if not tool_model_enabled:\n model_ids.append(model_name)\n # If tool_model_enabled is True but no capabilities info, skip the model\n # since we can't verify tool support\n elif self.DESIRED_CAPABILITY in capabilities and (\n not tool_model_enabled or self.TOOL_CALLING_CAPABILITY in capabilities\n ):\n model_ids.append(model_name)\n\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n def _parse_format_field(self, format_value: Any) -> Any:\n \"\"\"Parse the format field to handle both string and dict inputs.\n\n The format field can be:\n - A simple string like \"json\" (backward compatibility)\n - A JSON string from NestedDictInput that needs parsing\n - A dict/JSON schema (already parsed)\n - None or empty\n\n Args:\n format_value: The raw format value from the input field\n\n Returns:\n Parsed format value as string, dict, or None\n \"\"\"\n if not format_value:\n return None\n\n schema = format_value\n if isinstance(format_value, list):\n schema = build_model_from_schema(format_value).model_json_schema()\n if schema == self.default_table_row_schema:\n return None # the rows are generic placeholder rows\n elif isinstance(format_value, str): # parse as json if string\n with suppress(json.JSONDecodeError): # e.g., literal \"json\" is valid for format field\n schema = json.loads(format_value)\n\n return schema or None\n\n async def _parse_json_response(self) -> Any:\n \"\"\"Parse the JSON response from the model.\n\n This method gets the text response and attempts to parse it as JSON.\n Works with models that have format='json' or a JSON schema set.\n\n Returns:\n Parsed JSON (dict, list, or primitive type)\n\n Raises:\n ValueError: If the response is not valid JSON\n \"\"\"\n message = await self.text_response()\n text = message.text if hasattr(message, \"text\") else str(message)\n\n if not text:\n msg = \"No response from model\"\n raise ValueError(msg)\n\n try:\n return json.loads(text)\n except json.JSONDecodeError as e:\n msg = f\"Invalid JSON response. Ensure model supports JSON output. Error: {e}\"\n raise ValueError(msg) from e\n\n async def build_data_output(self) -> Data:\n \"\"\"Build a Data output from the model's JSON response.\n\n Returns:\n Data: A Data object containing the parsed JSON response\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If the response is already a dict, wrap it in Data\n if isinstance(parsed, dict):\n return Data(data=parsed)\n\n # If it's a list, wrap in a results container\n if isinstance(parsed, list):\n if len(parsed) == 1:\n return Data(data=parsed[0])\n return Data(data={\"results\": parsed})\n\n # For primitive types, wrap in a value container\n return Data(data={\"value\": parsed})\n\n async def build_dataframe_output(self) -> DataFrame:\n \"\"\"Build a DataFrame output from the model's JSON response.\n\n Returns:\n DataFrame: A DataFrame containing the parsed JSON response\n\n Raises:\n ValueError: If the response cannot be converted to a DataFrame\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If it's a list of dicts, convert directly to DataFrame\n if isinstance(parsed, list):\n if not parsed:\n return DataFrame()\n # Ensure all items are dicts for proper DataFrame conversion\n if all(isinstance(item, dict) for item in parsed):\n return DataFrame(parsed)\n msg = \"List items must be dictionaries to convert to DataFrame\"\n raise ValueError(msg)\n\n # If it's a single dict, wrap in a list to create a single-row DataFrame\n if isinstance(parsed, dict):\n return DataFrame([parsed])\n\n # For primitive types, create a single-column DataFrame\n return DataFrame([{\"value\": parsed}])\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n"
+ "value": "import asyncio\nimport json\nfrom contextlib import suppress\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import ChatOllama\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.base_model import build_model_from_schema\nfrom lfx.io import (\n BoolInput,\n DictInput,\n DropdownInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n TableInput,\n)\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.table import EditMode\nfrom lfx.utils.ssrf_httpx import (\n ssrf_protected_httpx_client_kwargs_for_url,\n ssrf_safe_async_get,\n ssrf_safe_async_post,\n)\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\nTABLE_ROW_PLACEHOLDER = {\"name\": \"field\", \"description\": \"description of field\", \"type\": \"str\", \"multiple\": \"False\"}\n\n\nclass ChatOllamaComponent(LCModelComponent):\n display_name = \"Ollama\"\n description = \"Generate text using Ollama Local LLMs.\"\n icon = \"Ollama\"\n name = \"OllamaModel\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n DESIRED_CAPABILITY = \"completion\"\n TOOL_CALLING_CAPABILITY = \"tools\"\n\n # Define the table schema for the format input\n TABLE_SCHEMA = [\n {\n \"name\": \"name\",\n \"display_name\": \"Name\",\n \"type\": \"str\",\n \"description\": \"Specify the name of the output field.\",\n \"default\": \"field\",\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"description\",\n \"display_name\": \"Description\",\n \"type\": \"str\",\n \"description\": \"Describe the purpose of the output field.\",\n \"default\": \"description of field\",\n \"edit_mode\": EditMode.POPOVER,\n },\n {\n \"name\": \"type\",\n \"display_name\": \"Type\",\n \"type\": \"str\",\n \"edit_mode\": EditMode.INLINE,\n \"description\": (\"Indicate the data type of the output field (e.g., str, int, float, bool, dict).\"),\n \"options\": [\"str\", \"int\", \"float\", \"bool\", \"dict\"],\n \"default\": \"str\",\n },\n {\n \"name\": \"multiple\",\n \"display_name\": \"As List\",\n \"type\": \"boolean\",\n \"description\": \"Set to True if this output field should be a list of the specified type.\",\n \"edit_mode\": EditMode.INLINE,\n \"options\": [\"True\", \"False\"],\n \"default\": \"False\",\n },\n ]\n default_table_row = {row[\"name\"]: row.get(\"default\", None) for row in TABLE_SCHEMA}\n default_table_row_schema = build_model_from_schema([default_table_row]).model_json_schema()\n\n inputs = [\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama API URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n options=[],\n info=\"Refer to https://ollama.com/library for more models.\",\n refresh_button=True,\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n TableInput(\n name=\"format\",\n display_name=\"Format\",\n info=\"Specify the format of the output.\",\n table_schema=TABLE_SCHEMA,\n value=default_table_row,\n show=False,\n ),\n DictInput(name=\"metadata\", display_name=\"Metadata\", info=\"Metadata to add to the run trace.\", advanced=True),\n DropdownInput(\n name=\"mirostat\",\n display_name=\"Mirostat\",\n options=[\"Disabled\", \"Mirostat\", \"Mirostat 2.0\"],\n info=\"Enable/disable Mirostat sampling for controlling perplexity.\",\n value=\"Disabled\",\n advanced=True,\n real_time_refresh=True,\n ),\n FloatInput(\n name=\"mirostat_eta\",\n display_name=\"Mirostat Eta\",\n info=\"Learning rate for Mirostat algorithm. (Default: 0.1)\",\n advanced=True,\n ),\n FloatInput(\n name=\"mirostat_tau\",\n display_name=\"Mirostat Tau\",\n info=\"Controls the balance between coherence and diversity of the output. (Default: 5.0)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_ctx\",\n display_name=\"Context Window Size\",\n info=\"Size of the context window for generating tokens. (Default: 2048)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_gpu\",\n display_name=\"Number of GPUs\",\n info=\"Number of GPUs to use for computation. (Default: 1 on macOS, 0 to disable)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_thread\",\n display_name=\"Number of Threads\",\n info=\"Number of threads to use during computation. (Default: detected for optimal performance)\",\n advanced=True,\n ),\n IntInput(\n name=\"repeat_last_n\",\n display_name=\"Repeat Last N\",\n info=\"How far back the model looks to prevent repetition. (Default: 64, 0 = disabled, -1 = num_ctx)\",\n advanced=True,\n ),\n FloatInput(\n name=\"repeat_penalty\",\n display_name=\"Repeat Penalty\",\n info=\"Penalty for repetitions in generated text. (Default: 1.1)\",\n advanced=True,\n ),\n FloatInput(name=\"tfs_z\", display_name=\"TFS Z\", info=\"Tail free sampling value. (Default: 1)\", advanced=True),\n IntInput(name=\"timeout\", display_name=\"Timeout\", info=\"Timeout for the request stream.\", advanced=True),\n IntInput(\n name=\"top_k\", display_name=\"Top K\", info=\"Limits token selection to top K. (Default: 40)\", advanced=True\n ),\n FloatInput(name=\"top_p\", display_name=\"Top P\", info=\"Works together with top-k. (Default: 0.9)\", advanced=True),\n BoolInput(\n name=\"enable_verbose_output\",\n display_name=\"Ollama Verbose Output\",\n info=\"Whether to print out response text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"tags\",\n display_name=\"Tags\",\n info=\"Comma-separated list of tags to add to the run trace.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"stop_tokens\",\n display_name=\"Stop Tokens\",\n info=\"Comma-separated list of tokens to signal the model to stop generating text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"system\", display_name=\"System\", info=\"System to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"tool_model_enabled\",\n display_name=\"Tool Model Enabled\",\n info=\"Whether to enable tool calling in the model.\",\n value=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"template\", display_name=\"Template\", info=\"Template to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"enable_structured_output\",\n display_name=\"Enable Structured Output\",\n info=\"Whether to enable structured output in the model.\",\n value=False,\n advanced=False,\n real_time_refresh=True,\n ),\n *LCModelComponent.get_base_inputs(),\n ]\n\n outputs = [\n Output(display_name=\"Text\", name=\"text_output\", method=\"text_response\"),\n Output(display_name=\"Language Model\", name=\"model_output\", method=\"build_model\"),\n Output(display_name=\"JSON\", name=\"data_output\", method=\"build_data_output\"),\n Output(display_name=\"Table\", name=\"dataframe_output\", method=\"build_dataframe_output\"),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n # Mapping mirostat settings to their corresponding values\n mirostat_options = {\"Mirostat\": 1, \"Mirostat 2.0\": 2}\n\n # Default to None for 'Disabled'\n mirostat_value = mirostat_options.get(self.mirostat, None)\n\n # Set mirostat_eta and mirostat_tau to None if mirostat is disabled\n if mirostat_value is None:\n mirostat_eta = None\n mirostat_tau = None\n else:\n mirostat_eta = self.mirostat_eta\n mirostat_tau = self.mirostat_tau\n\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Check if URL contains /v1 suffix (OpenAI-compatible mode)\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n # Strip /v1 suffix and log warning\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n sync_client_kwargs, async_client_kwargs = ssrf_protected_httpx_client_kwargs_for_url(transformed_base_url)\n\n try:\n output_format = self._parse_format_field(self.format) if self.enable_structured_output else None\n except Exception as e:\n msg = f\"Failed to parse the format field: {e}\"\n raise ValueError(msg) from e\n\n # Mapping system settings to their corresponding values\n llm_params = {\n \"base_url\": transformed_base_url,\n \"model\": self.model_name,\n \"mirostat\": mirostat_value,\n \"format\": output_format or None,\n \"metadata\": self.metadata,\n \"tags\": self.tags.split(\",\") if self.tags else None,\n \"mirostat_eta\": mirostat_eta,\n \"mirostat_tau\": mirostat_tau,\n \"num_ctx\": self.num_ctx or None,\n \"num_gpu\": self.num_gpu or None,\n \"num_thread\": self.num_thread or None,\n \"repeat_last_n\": self.repeat_last_n or None,\n \"repeat_penalty\": self.repeat_penalty or None,\n \"temperature\": self.temperature or None,\n \"stop\": self.stop_tokens.split(\",\") if self.stop_tokens else None,\n \"system\": self.system,\n \"tfs_z\": self.tfs_z or None,\n \"timeout\": self.timeout or None,\n \"top_k\": self.top_k or None,\n \"top_p\": self.top_p or None,\n \"verbose\": self.enable_verbose_output or False,\n \"template\": self.template,\n }\n headers = self.headers\n if headers is not None:\n llm_params[\"client_kwargs\"] = {\"headers\": headers}\n if sync_client_kwargs:\n llm_params[\"sync_client_kwargs\"] = sync_client_kwargs\n if async_client_kwargs:\n llm_params[\"async_client_kwargs\"] = async_client_kwargs\n\n # Remove parameters with None values\n llm_params = {k: v for k, v in llm_params.items() if v is not None}\n\n try:\n output = ChatOllama(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n\n return output\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n response = await ssrf_safe_async_get(urljoin(url, \"api/tags\"), headers=self.headers)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except httpx.RequestError:\n return False\n else:\n return response.status_code == HTTP_STATUS_OK\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name == \"enable_structured_output\": # bind enable_structured_output boolean to format show value\n build_config[\"format\"][\"show\"] = field_value\n\n if field_name == \"mirostat\":\n if field_value == \"Disabled\":\n build_config[\"mirostat_eta\"][\"advanced\"] = True\n build_config[\"mirostat_tau\"][\"advanced\"] = True\n build_config[\"mirostat_eta\"][\"value\"] = None\n build_config[\"mirostat_tau\"][\"value\"] = None\n\n else:\n build_config[\"mirostat_eta\"][\"advanced\"] = False\n build_config[\"mirostat_tau\"][\"advanced\"] = False\n\n if field_value == \"Mirostat 2.0\":\n build_config[\"mirostat_eta\"][\"value\"] = 0.2\n build_config[\"mirostat_tau\"][\"value\"] = 10\n else:\n build_config[\"mirostat_eta\"][\"value\"] = 0.1\n build_config[\"mirostat_tau\"][\"value\"] = 5\n\n if field_name in {\"model_name\", \"base_url\", \"tool_model_enabled\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n tool_model_enabled = build_config[\"tool_model_enabled\"].get(\"value\", False) or self.tool_model_enabled\n build_config[\"model_name\"][\"options\"] = await self.get_models(\n base_url_to_check, tool_model_enabled=tool_model_enabled\n )\n else:\n build_config[\"model_name\"][\"options\"] = []\n if field_name == \"keep_alive_flag\":\n if field_value == \"Keep\":\n build_config[\"keep_alive\"][\"value\"] = \"-1\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n elif field_value == \"Immediately\":\n build_config[\"keep_alive\"][\"value\"] = \"0\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n else:\n build_config[\"keep_alive\"][\"advanced\"] = False\n\n return build_config\n\n async def get_models(self, base_url_value: str, *, tool_model_enabled: bool | None = None) -> list[str]:\n \"\"\"Fetches a list of models from the Ollama API suitable for text generation.\n\n Args:\n base_url_value (str): The base URL of the Ollama API.\n tool_model_enabled (bool | None, optional): If True, filters the models further to include\n only those that support tool calling. Defaults to None.\n\n Returns:\n list[str]: A list of model names suitable for text generation. Models are included if:\n - They have the \"completion\" capability, OR\n - The capabilities field is not returned (backwards compatibility with older Ollama versions)\n If `tool_model_enabled` is True, only models with verified \"tools\" capability are included\n (models without capabilities info are excluded in this case).\n\n Raises:\n ValueError: If there is an issue with the API request or response, or if the model\n names cannot be retrieved.\n \"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n headers = self.headers\n # Fetch available models\n tags_response = await ssrf_safe_async_get(tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are NOT embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await ssrf_safe_async_post(show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY)\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n # If capabilities not provided, assume it's a completion model (backwards compatibility\n # with older Ollama versions that don't return capabilities from /api/show)\n if capabilities is None:\n if not tool_model_enabled:\n model_ids.append(model_name)\n # If tool_model_enabled is True but no capabilities info, skip the model\n # since we can't verify tool support\n elif self.DESIRED_CAPABILITY in capabilities and (\n not tool_model_enabled or self.TOOL_CALLING_CAPABILITY in capabilities\n ):\n model_ids.append(model_name)\n\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n def _parse_format_field(self, format_value: Any) -> Any:\n \"\"\"Parse the format field to handle both string and dict inputs.\n\n The format field can be:\n - A simple string like \"json\" (backward compatibility)\n - A JSON string from NestedDictInput that needs parsing\n - A dict/JSON schema (already parsed)\n - None or empty\n\n Args:\n format_value: The raw format value from the input field\n\n Returns:\n Parsed format value as string, dict, or None\n \"\"\"\n if not format_value:\n return None\n\n schema = format_value\n if isinstance(format_value, list):\n schema = build_model_from_schema(format_value).model_json_schema()\n if schema == self.default_table_row_schema:\n return None # the rows are generic placeholder rows\n elif isinstance(format_value, str): # parse as json if string\n with suppress(json.JSONDecodeError): # e.g., literal \"json\" is valid for format field\n schema = json.loads(format_value)\n\n return schema or None\n\n async def _parse_json_response(self) -> Any:\n \"\"\"Parse the JSON response from the model.\n\n This method gets the text response and attempts to parse it as JSON.\n Works with models that have format='json' or a JSON schema set.\n\n Returns:\n Parsed JSON (dict, list, or primitive type)\n\n Raises:\n ValueError: If the response is not valid JSON\n \"\"\"\n message = await self.text_response()\n text = message.text if hasattr(message, \"text\") else str(message)\n\n if not text:\n msg = \"No response from model\"\n raise ValueError(msg)\n\n try:\n return json.loads(text)\n except json.JSONDecodeError as e:\n msg = f\"Invalid JSON response. Ensure model supports JSON output. Error: {e}\"\n raise ValueError(msg) from e\n\n async def build_data_output(self) -> Data:\n \"\"\"Build a Data output from the model's JSON response.\n\n Returns:\n Data: A Data object containing the parsed JSON response\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If the response is already a dict, wrap it in Data\n if isinstance(parsed, dict):\n return Data(data=parsed)\n\n # If it's a list, wrap in a results container\n if isinstance(parsed, list):\n if len(parsed) == 1:\n return Data(data=parsed[0])\n return Data(data={\"results\": parsed})\n\n # For primitive types, wrap in a value container\n return Data(data={\"value\": parsed})\n\n async def build_dataframe_output(self) -> DataFrame:\n \"\"\"Build a DataFrame output from the model's JSON response.\n\n Returns:\n DataFrame: A DataFrame containing the parsed JSON response\n\n Raises:\n ValueError: If the response cannot be converted to a DataFrame\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If it's a list of dicts, convert directly to DataFrame\n if isinstance(parsed, list):\n if not parsed:\n return DataFrame()\n # Ensure all items are dicts for proper DataFrame conversion\n if all(isinstance(item, dict) for item in parsed):\n return DataFrame(parsed)\n msg = \"List items must be dictionaries to convert to DataFrame\"\n raise ValueError(msg)\n\n # If it's a single dict, wrap in a list to create a single-row DataFrame\n if isinstance(parsed, dict):\n return DataFrame([parsed])\n\n # For primitive types, create a single-column DataFrame\n return DataFrame([{\"value\": parsed}])\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n"
},
"enable_structured_output": {
"_input_type": "BoolInput",
@@ -4910,7 +4910,7 @@
"legacy": false,
"lf_version": "1.8.1",
"metadata": {
- "code_hash": "a8c56d0835de",
+ "code_hash": "e42e16033136",
"dependencies": {
"dependencies": [
{
@@ -5015,7 +5015,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import asyncio\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import OllamaEmbeddings\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import DropdownInput, Output, SecretStrInput, StrInput\nfrom lfx.log.logger import logger\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\n\n\nclass OllamaEmbeddingsComponent(LCModelComponent):\n display_name: str = \"Ollama Embeddings\"\n description: str = \"Generate embeddings using Ollama models.\"\n documentation = \"https://python.langchain.com/docs/integrations/text_embedding/ollama\"\n icon = \"Ollama\"\n name = \"OllamaEmbeddings\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n EMBEDDING_CAPABILITY = \"embedding\"\n\n inputs = [\n DropdownInput(\n name=\"model_name\",\n display_name=\"Ollama Model\",\n value=\"\",\n options=[],\n real_time_refresh=True,\n refresh_button=True,\n combobox=True,\n required=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama Base URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n required=True,\n real_time_refresh=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Embeddings\", name=\"embeddings\", method=\"build_embeddings\"),\n ]\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n\n def build_embeddings(self) -> Embeddings:\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Strip /v1 suffix if present\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n llm_params = {\n \"model\": self.model_name,\n \"base_url\": transformed_base_url,\n }\n\n if self.headers:\n llm_params[\"client_kwargs\"] = {\"headers\": self.headers}\n\n try:\n output = OllamaEmbeddings(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n return output\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name in {\"base_url\", \"model_name\"} and not await self.is_valid_ollama_url(self.base_url):\n msg = \"Ollama is not running on the provided base URL. Please start Ollama and try again.\"\n raise ValueError(msg)\n if field_name in {\"model_name\", \"base_url\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n build_config[\"model_name\"][\"options\"] = await self.get_model(base_url_to_check)\n else:\n build_config[\"model_name\"][\"options\"] = []\n\n return build_config\n\n async def get_model(self, base_url_value: str) -> list[str]:\n \"\"\"Get the model names from Ollama.\"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n async with httpx.AsyncClient() as client:\n headers = self.headers\n # Fetch available models\n tags_response = await client.get(url=tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await client.post(url=show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY, [])\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n if self.EMBEDDING_CAPABILITY in capabilities:\n model_ids.append(model_name)\n\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n async with httpx.AsyncClient() as client:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n return (\n await client.get(url=urljoin(url, \"api/tags\"), headers=self.headers)\n ).status_code == HTTP_STATUS_OK\n except httpx.RequestError:\n return False\n"
+ "value": "import asyncio\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import OllamaEmbeddings\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import DropdownInput, Output, SecretStrInput, StrInput\nfrom lfx.log.logger import logger\nfrom lfx.utils.ssrf_httpx import (\n ssrf_protected_httpx_client_kwargs_for_url,\n ssrf_safe_async_get,\n ssrf_safe_async_post,\n)\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\n\n\nclass OllamaEmbeddingsComponent(LCModelComponent):\n display_name: str = \"Ollama Embeddings\"\n description: str = \"Generate embeddings using Ollama models.\"\n documentation = \"https://python.langchain.com/docs/integrations/text_embedding/ollama\"\n icon = \"Ollama\"\n name = \"OllamaEmbeddings\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n EMBEDDING_CAPABILITY = \"embedding\"\n\n inputs = [\n DropdownInput(\n name=\"model_name\",\n display_name=\"Ollama Model\",\n value=\"\",\n options=[],\n real_time_refresh=True,\n refresh_button=True,\n combobox=True,\n required=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama Base URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n required=True,\n real_time_refresh=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Embeddings\", name=\"embeddings\", method=\"build_embeddings\"),\n ]\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n\n def build_embeddings(self) -> Embeddings:\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Strip /v1 suffix if present\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n sync_client_kwargs, async_client_kwargs = ssrf_protected_httpx_client_kwargs_for_url(transformed_base_url)\n\n llm_params = {\n \"model\": self.model_name,\n \"base_url\": transformed_base_url,\n }\n\n if sync_client_kwargs:\n llm_params[\"sync_client_kwargs\"] = sync_client_kwargs\n if async_client_kwargs:\n llm_params[\"async_client_kwargs\"] = async_client_kwargs\n if self.headers:\n llm_params[\"client_kwargs\"] = {\"headers\": self.headers}\n\n try:\n output = OllamaEmbeddings(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n return output\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name in {\"base_url\", \"model_name\"} and not await self.is_valid_ollama_url(self.base_url):\n msg = \"Ollama is not running on the provided base URL. Please start Ollama and try again.\"\n raise ValueError(msg)\n if field_name in {\"model_name\", \"base_url\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n build_config[\"model_name\"][\"options\"] = await self.get_model(base_url_to_check)\n else:\n build_config[\"model_name\"][\"options\"] = []\n\n return build_config\n\n async def get_model(self, base_url_value: str) -> list[str]:\n \"\"\"Get the model names from Ollama.\"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n headers = self.headers\n # Fetch available models\n tags_response = await ssrf_safe_async_get(tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await ssrf_safe_async_post(show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY, [])\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n if self.EMBEDDING_CAPABILITY in capabilities:\n model_ids.append(model_name)\n\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n response = await ssrf_safe_async_get(urljoin(url, \"api/tags\"), headers=self.headers)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except httpx.RequestError:\n return False\n else:\n return response.status_code == HTTP_STATUS_OK\n"
},
"model_name": {
"_input_type": "DropdownInput",
diff --git a/src/backend/tests/integration/components/languagemodels/test_chatollama_integration.py b/src/backend/tests/integration/components/languagemodels/test_chatollama_integration.py
index b3faa5c4cabf..a389a33c8338 100644
--- a/src/backend/tests/integration/components/languagemodels/test_chatollama_integration.py
+++ b/src/backend/tests/integration/components/languagemodels/test_chatollama_integration.py
@@ -7,6 +7,11 @@
from lfx.schema.message import Message
+@pytest.fixture(autouse=True)
+def allow_local_ollama(monkeypatch):
+ monkeypatch.setenv("LANGFLOW_SSRF_ALLOWED_HOSTS", "localhost")
+
+
@pytest.mark.integration
class TestChatOllamaIntegration:
"""Integration tests for ChatOllama structured output flow."""
diff --git a/src/backend/tests/unit/components/embeddings/test_ollama_embeddings_component.py b/src/backend/tests/unit/components/embeddings/test_ollama_embeddings_component.py
index 1eb56b2a4751..bc3048cc638c 100644
--- a/src/backend/tests/unit/components/embeddings/test_ollama_embeddings_component.py
+++ b/src/backend/tests/unit/components/embeddings/test_ollama_embeddings_component.py
@@ -919,3 +919,7 @@ def test_component_outputs(self, component_class):
component = component_class()
output_names = [out.name for out in component.outputs]
assert "embeddings" in output_names
+
+ @pytest.fixture(autouse=True)
+ def disable_ssrf_protection(self, monkeypatch):
+ monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "false")
diff --git a/src/backend/tests/unit/components/languagemodels/test_chatollama_component.py b/src/backend/tests/unit/components/languagemodels/test_chatollama_component.py
index 1e955382c1de..b3830e8dff57 100644
--- a/src/backend/tests/unit/components/languagemodels/test_chatollama_component.py
+++ b/src/backend/tests/unit/components/languagemodels/test_chatollama_component.py
@@ -1260,3 +1260,7 @@ def test_build_model_structured_output_toggle_behavior(self, mock_chat_ollama, c
assert "format" in call_args, "format should be in call when enabled"
assert call_args["format"] == "json"
assert model == mock_instance
+
+ @pytest.fixture(autouse=True)
+ def disable_ssrf_protection(self, monkeypatch):
+ monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "false")
diff --git a/src/backend/tests/unit/components/languagemodels/test_deepseek.py b/src/backend/tests/unit/components/languagemodels/test_deepseek.py
index 2ca364164abc..3f5baf06dc2c 100644
--- a/src/backend/tests/unit/components/languagemodels/test_deepseek.py
+++ b/src/backend/tests/unit/components/languagemodels/test_deepseek.py
@@ -82,8 +82,8 @@ def test_deepseek_build_model(mock_chat_openai, temperature, max_tokens):
def test_deepseek_get_models(mocker):
component = DeepSeekModelComponent()
- # Mock requests.get
- mock_get = mocker.patch("requests.get")
+ # Mock SSRF-safe httpx helper
+ mock_get = mocker.patch("lfx.components.deepseek.deepseek.ssrf_safe_httpx_get")
mock_response = MagicMock()
mock_response.json.return_value = {"data": [{"id": "deepseek-chat"}, {"id": "deepseek-coder"}]}
mock_get.return_value = mock_response
@@ -110,3 +110,8 @@ def test_deepseek_error_handling(mock_chat_openai):
with pytest.raises(Exception, match="Invalid API key"):
component.build_model()
+
+
+@pytest.fixture(autouse=True)
+def disable_ssrf_protection(monkeypatch):
+ monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "false")
diff --git a/src/backend/tests/unit/components/languagemodels/test_litellm_proxy.py b/src/backend/tests/unit/components/languagemodels/test_litellm_proxy.py
index 379770862e36..b02d5a51f6b2 100644
--- a/src/backend/tests/unit/components/languagemodels/test_litellm_proxy.py
+++ b/src/backend/tests/unit/components/languagemodels/test_litellm_proxy.py
@@ -72,7 +72,7 @@ def test_build_model(self, component_class, default_kwargs, mocker):
component = component_class(**default_kwargs)
mocker.patch(
- "lfx.components.litellm.litellm_proxy.httpx.get",
+ "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get",
return_value=_mock_models_response(),
)
mock_chat_openai = mocker.patch(
@@ -98,7 +98,7 @@ def test_build_model_secret_str_api_key(self, component_class, default_kwargs, m
component = component_class(**default_kwargs)
mocker.patch(
- "lfx.components.litellm.litellm_proxy.httpx.get",
+ "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get",
return_value=_mock_models_response(),
)
mock_chat_openai = mocker.patch(
@@ -116,7 +116,7 @@ def test_build_model_max_tokens_zero(self, component_class, default_kwargs, mock
component = component_class(**default_kwargs)
mocker.patch(
- "lfx.components.litellm.litellm_proxy.httpx.get",
+ "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get",
return_value=_mock_models_response(),
)
mock_chat_openai = mocker.patch(
@@ -133,7 +133,7 @@ def test_build_model_max_tokens_zero(self, component_class, default_kwargs, mock
def test_validate_proxy_connection_success(self, component_class, default_kwargs, mocker):
component = component_class(**default_kwargs)
mocker.patch(
- "lfx.components.litellm.litellm_proxy.httpx.get",
+ "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get",
return_value=_mock_models_response(),
)
# Should not raise
@@ -142,7 +142,7 @@ def test_validate_proxy_connection_success(self, component_class, default_kwargs
def test_validate_proxy_connection_auth_failure(self, component_class, default_kwargs, mocker):
component = component_class(**default_kwargs)
mocker.patch(
- "lfx.components.litellm.litellm_proxy.httpx.get",
+ "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get",
return_value=_mock_models_response(status_code=401),
)
with pytest.raises(ValueError, match="Authentication failed"):
@@ -152,7 +152,7 @@ def test_validate_proxy_connection_model_not_found(self, component_class, defaul
default_kwargs["model_name"] = "invalid-model-name"
component = component_class(**default_kwargs)
mocker.patch(
- "lfx.components.litellm.litellm_proxy.httpx.get",
+ "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get",
return_value=_mock_models_response(models=[{"id": "gpt-4o"}]),
)
with pytest.raises(ValueError, match=r"invalid-model-name.*not found"):
@@ -161,7 +161,7 @@ def test_validate_proxy_connection_model_not_found(self, component_class, defaul
def test_validate_proxy_connection_connect_error(self, component_class, default_kwargs, mocker):
component = component_class(**default_kwargs)
mocker.patch(
- "lfx.components.litellm.litellm_proxy.httpx.get",
+ "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get",
side_effect=httpx.ConnectError("Connection refused"),
)
with pytest.raises(ValueError, match="Could not connect"):
@@ -170,7 +170,7 @@ def test_validate_proxy_connection_connect_error(self, component_class, default_
def test_validate_proxy_connection_timeout(self, component_class, default_kwargs, mocker):
component = component_class(**default_kwargs)
mocker.patch(
- "lfx.components.litellm.litellm_proxy.httpx.get",
+ "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get",
side_effect=httpx.TimeoutException("Timed out"),
)
with pytest.raises(ValueError, match="timed out"):
@@ -179,7 +179,7 @@ def test_validate_proxy_connection_timeout(self, component_class, default_kwargs
def test_validate_proxy_connection_empty_models_list(self, component_class, default_kwargs, mocker):
component = component_class(**default_kwargs)
mocker.patch(
- "lfx.components.litellm.litellm_proxy.httpx.get",
+ "lfx.components.litellm.litellm_proxy.ssrf_safe_httpx_get",
return_value=_mock_models_response(models=[]),
)
# Empty models list should not raise (proxy may not report models)
@@ -238,3 +238,7 @@ def test_get_exception_message_no_openai_import(self, component_class, default_k
with patch.dict("sys.modules", {"openai": None}):
message = component._get_exception_message(Exception("test"))
assert message is None
+
+ @pytest.fixture(autouse=True)
+ def disable_ssrf_protection(self, monkeypatch):
+ monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "false")
diff --git a/src/backend/tests/unit/components/languagemodels/test_xai.py b/src/backend/tests/unit/components/languagemodels/test_xai.py
index f4f4ff523263..532d3669c78a 100644
--- a/src/backend/tests/unit/components/languagemodels/test_xai.py
+++ b/src/backend/tests/unit/components/languagemodels/test_xai.py
@@ -101,8 +101,15 @@ def test_build_model(self, component_class, default_kwargs, mocker):
component.base_url = "https://api.x.ai/v1"
component.seed = 1
+ http_client = MagicMock()
+ http_async_client = MagicMock()
+ mock_client_kwargs = mocker.patch(
+ "lfx.components.xai.xai.ssrf_protected_openai_clients_for_url",
+ return_value={"http_client": http_client, "http_async_client": http_async_client},
+ )
mock_chat_openai = mocker.patch("lfx.components.xai.xai.ChatOpenAI", return_value=MagicMock())
model = component.build_model()
+ mock_client_kwargs.assert_called_once_with("https://api.x.ai/v1")
mock_chat_openai.assert_called_once_with(
max_tokens=100,
model_kwargs={},
@@ -111,12 +118,14 @@ def test_build_model(self, component_class, default_kwargs, mocker):
api_key="test-key",
temperature=0.7,
seed=1,
+ http_client=http_client,
+ http_async_client=http_async_client,
)
assert model == mock_chat_openai.return_value
def test_get_models(self):
component = XAIModelComponent()
- with patch("requests.get") as mock_get:
+ with patch("lfx.components.xai.xai.ssrf_safe_httpx_get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {
"models": [
@@ -191,8 +200,11 @@ def test_update_build_config(self):
component = XAIModelComponent()
build_config = {"model_name": {"options": []}}
- updated_config = component.update_build_config(build_config, "test-key", "api_key")
- assert "model_name" in updated_config
+ with patch.object(component, "get_models", return_value=["grok-2-latest"]) as mock_get_models:
+ updated_config = component.update_build_config(build_config, "test-key", "api_key")
+ assert "model_name" in updated_config
+
+ updated_config = component.update_build_config(build_config, "grok-2-latest", "model_name")
+ assert "model_name" in updated_config
- updated_config = component.update_build_config(build_config, "grok-2-latest", "model_name")
- assert "model_name" in updated_config
+ assert mock_get_models.call_count == 2
diff --git a/src/backend/tests/unit/components/test_ssrf_guarded_url_components.py b/src/backend/tests/unit/components/test_ssrf_guarded_url_components.py
new file mode 100644
index 000000000000..09f477a927fb
--- /dev/null
+++ b/src/backend/tests/unit/components/test_ssrf_guarded_url_components.py
@@ -0,0 +1,183 @@
+from unittest.mock import patch
+
+import pytest
+from lfx.components.deepseek.deepseek import DEEPSEEK_MODELS, DeepSeekModelComponent
+from lfx.components.glean.glean_search_api import GleanAPIWrapper
+from lfx.components.homeassistant.home_assistant_control import HomeAssistantControl
+from lfx.components.homeassistant.list_home_assistant_states import ListHomeAssistantStates
+from lfx.components.huggingface.huggingface_inference_api import HuggingFaceInferenceAPIEmbeddingsComponent
+from lfx.components.litellm.litellm_proxy import LiteLLMProxyComponent
+from lfx.components.lmstudio.lmstudioembeddings import LMStudioEmbeddingsComponent
+from lfx.components.lmstudio.lmstudiomodel import LMStudioModelComponent
+from lfx.components.ollama.ollama import ChatOllamaComponent
+from lfx.components.ollama.ollama_embeddings import OllamaEmbeddingsComponent
+from lfx.components.xai.xai import XAI_DEFAULT_MODELS, XAIModelComponent
+from lfx.utils.ssrf_protection import SSRFProtectionError
+
+BLOCKED_URL = "http://169.254.169.254/latest/meta-data"
+
+
+@pytest.fixture(autouse=True)
+def enable_ssrf_protection(monkeypatch):
+ monkeypatch.setenv("LANGFLOW_SSRF_PROTECTION_ENABLED", "true")
+ monkeypatch.delenv("LANGFLOW_SSRF_ALLOWED_HOSTS", raising=False)
+
+
+@pytest.mark.asyncio
+async def test_lmstudio_model_update_blocks_metadata_url_before_httpx():
+ component = LMStudioModelComponent()
+ build_config = {"base_url": {"load_from_db": False, "value": BLOCKED_URL}, "model_name": {"options": []}}
+
+ with patch("httpx.AsyncClient.get") as mock_get, pytest.raises(ValueError, match="SSRF Protection"):
+ await component.update_build_config(build_config, None, "model_name")
+
+ mock_get.assert_not_called()
+
+
+def test_lmstudio_model_build_blocks_metadata_url_before_openai_client():
+ component = LMStudioModelComponent(base_url=BLOCKED_URL, model_name="model", api_key="test")
+
+ with (
+ patch("lfx.components.lmstudio.lmstudiomodel.ChatOpenAI") as mock_chat_openai,
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ component.build_model()
+
+ mock_chat_openai.assert_not_called()
+
+
+def test_lmstudio_embeddings_build_blocks_metadata_url_before_sdk_client():
+ component = LMStudioEmbeddingsComponent(base_url=BLOCKED_URL, model="model", api_key="test")
+
+ with (
+ patch("lfx.components.lmstudio.lmstudioembeddings.NVIDIAEmbeddings", create=True) as mock_embeddings,
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ component.build_embeddings()
+
+ mock_embeddings.assert_not_called()
+
+
+def test_home_assistant_list_states_blocks_metadata_url_before_httpx():
+ component = ListHomeAssistantStates()
+
+ with patch("httpx.Client.get") as mock_get:
+ result = component._list_states("token", BLOCKED_URL)
+
+ assert "SSRF Protection" in result
+ mock_get.assert_not_called()
+
+
+def test_home_assistant_control_blocks_metadata_url_before_httpx():
+ component = HomeAssistantControl()
+
+ with patch("httpx.Client.post") as mock_post:
+ result = component._control_device("token", BLOCKED_URL, "turn_on", "switch.test")
+
+ assert "SSRF Protection" in result
+ mock_post.assert_not_called()
+
+
+def test_deepseek_model_fetch_blocks_metadata_url_before_httpx():
+ component = DeepSeekModelComponent(api_base=BLOCKED_URL, api_key="test")
+
+ with patch("httpx.Client.get") as mock_get:
+ models = component.get_models()
+
+ assert models == DEEPSEEK_MODELS
+ assert "SSRF Protection" in component.status
+ mock_get.assert_not_called()
+
+
+def test_deepseek_build_blocks_metadata_url_before_openai_client():
+ component = DeepSeekModelComponent(api_base=BLOCKED_URL, api_key="test")
+
+ with patch("langchain_openai.ChatOpenAI") as mock_chat_openai, pytest.raises(ValueError, match="SSRF Protection"):
+ component.build_model()
+
+ mock_chat_openai.assert_not_called()
+
+
+def test_xai_model_fetch_blocks_metadata_url_before_httpx():
+ component = XAIModelComponent(base_url=BLOCKED_URL, api_key="test")
+
+ with patch("httpx.Client.get") as mock_get:
+ models = component.get_models()
+
+ assert models == XAI_DEFAULT_MODELS
+ assert "SSRF Protection" in component.status
+ mock_get.assert_not_called()
+
+
+def test_xai_build_blocks_metadata_url_before_openai_client():
+ component = XAIModelComponent(base_url=BLOCKED_URL, api_key="test")
+
+ with (
+ patch("lfx.components.xai.xai.ChatOpenAI") as mock_chat_openai,
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ component.build_model()
+
+ mock_chat_openai.assert_not_called()
+
+
+def test_glean_blocks_metadata_url_before_httpx_post():
+ wrapper = GleanAPIWrapper(glean_api_url=BLOCKED_URL, glean_access_token="test-access-token") # noqa: S106
+
+ with patch("httpx.Client.post") as mock_post, pytest.raises(SSRFProtectionError):
+ wrapper._search_api_results("query")
+
+ mock_post.assert_not_called()
+
+
+def test_huggingface_build_blocks_metadata_url_before_sdk_client():
+ component = HuggingFaceInferenceAPIEmbeddingsComponent(
+ inference_endpoint=BLOCKED_URL,
+ model_name="model",
+ )
+
+ with (
+ patch.object(component, "create_huggingface_embeddings") as mock_create,
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ component.build_embeddings()
+
+ mock_create.assert_not_called()
+
+
+def test_ollama_embeddings_build_blocks_metadata_url_before_sdk_client():
+ component = OllamaEmbeddingsComponent(base_url=BLOCKED_URL, model_name="model")
+
+ with (
+ patch("lfx.components.ollama.ollama_embeddings.OllamaEmbeddings") as mock_embeddings,
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ component.build_embeddings()
+
+ mock_embeddings.assert_not_called()
+
+
+def test_ollama_build_blocks_metadata_url_before_sdk_client():
+ component = ChatOllamaComponent(base_url=BLOCKED_URL, model_name="model", mirostat="Disabled")
+
+ with (
+ patch("lfx.components.ollama.ollama.ChatOllama") as mock_chat_ollama,
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ component.build_model()
+
+ mock_chat_ollama.assert_not_called()
+
+
+def test_litellm_build_blocks_metadata_url_before_httpx_and_openai_client():
+ component = LiteLLMProxyComponent(api_base=BLOCKED_URL, api_key="test", model_name="model")
+
+ with (
+ patch("httpx.Client.get") as mock_get,
+ patch("lfx.components.litellm.litellm_proxy.ChatOpenAI") as mock_chat_openai,
+ pytest.raises(ValueError, match="SSRF Protection"),
+ ):
+ component.build_model()
+
+ mock_get.assert_not_called()
+ mock_chat_openai.assert_not_called()
diff --git a/src/lfx/src/lfx/_assets/component_index.json b/src/lfx/src/lfx/_assets/component_index.json
index c61150bb9055..6bbc0604eb06 100644
--- a/src/lfx/src/lfx/_assets/component_index.json
+++ b/src/lfx/src/lfx/_assets/component_index.json
@@ -65119,12 +65119,12 @@
"icon": "DeepSeek",
"legacy": false,
"metadata": {
- "code_hash": "c8dac7a258d7",
+ "code_hash": "f2587bdfb773",
"dependencies": {
"dependencies": [
{
- "name": "requests",
- "version": "2.34.2"
+ "name": "httpx",
+ "version": "0.28.1"
},
{
"name": "pydantic",
@@ -65248,7 +65248,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import requests\nfrom pydantic.v1 import SecretStr\nfrom typing_extensions import override\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.inputs.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput\n\nDEEPSEEK_MODELS = [\"deepseek-chat\"]\n\n\nclass DeepSeekModelComponent(LCModelComponent):\n display_name = \"DeepSeek\"\n description = \"Generate text using DeepSeek LLMs.\"\n icon = \"DeepSeek\"\n\n inputs = [\n *LCModelComponent.get_base_inputs(),\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"Maximum number of tokens to generate. Set to 0 for unlimited.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n info=\"DeepSeek model to use\",\n options=DEEPSEEK_MODELS,\n value=\"deepseek-chat\",\n refresh_button=True,\n ),\n StrInput(\n name=\"api_base\",\n display_name=\"DeepSeek API Base\",\n advanced=True,\n info=\"Base URL for API requests. Defaults to https://api.deepseek.com\",\n value=\"https://api.deepseek.com\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"DeepSeek API Key\",\n info=\"The DeepSeek API Key\",\n advanced=False,\n required=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n info=\"Controls randomness in responses\",\n value=1.0,\n range_spec=RangeSpec(min=0, max=2, step=0.01),\n advanced=True,\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n ]\n\n def get_models(self) -> list[str]:\n if not self.api_key:\n return DEEPSEEK_MODELS\n\n url = f\"{self.api_base}/models\"\n headers = {\"Authorization\": f\"Bearer {self.api_key}\", \"Accept\": \"application/json\"}\n\n try:\n response = requests.get(url, headers=headers, timeout=10)\n response.raise_for_status()\n model_list = response.json()\n return [model[\"id\"] for model in model_list.get(\"data\", [])]\n except requests.RequestException as e:\n self.status = f\"Error fetching models: {e}\"\n return DEEPSEEK_MODELS\n\n @override\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n if field_name in {\"api_key\", \"api_base\", \"model_name\"}:\n models = self.get_models()\n build_config[\"model_name\"][\"options\"] = models\n return build_config\n\n def build_model(self) -> LanguageModel:\n try:\n from langchain_openai import ChatOpenAI\n except ImportError as e:\n msg = \"langchain-openai not installed. Please install with `pip install langchain-openai`\"\n raise ImportError(msg) from e\n\n api_key = SecretStr(self.api_key).get_secret_value() if self.api_key else None\n output = ChatOpenAI(\n model=self.model_name,\n temperature=self.temperature if self.temperature is not None else 0.1,\n max_tokens=self.max_tokens or None,\n model_kwargs=self.model_kwargs or {},\n base_url=self.api_base,\n api_key=api_key,\n streaming=self.stream if hasattr(self, \"stream\") else False,\n seed=self.seed,\n )\n\n if self.json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get message from DeepSeek API exception.\"\"\"\n try:\n from openai import BadRequestError\n\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n except ImportError:\n pass\n return None\n"
+ "value": "import httpx\nfrom pydantic.v1 import SecretStr\nfrom typing_extensions import override\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.inputs.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput\nfrom lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_httpx_get\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\n\nDEEPSEEK_MODELS = [\"deepseek-chat\"]\n\n\nclass DeepSeekModelComponent(LCModelComponent):\n display_name = \"DeepSeek\"\n description = \"Generate text using DeepSeek LLMs.\"\n icon = \"DeepSeek\"\n\n inputs = [\n *LCModelComponent.get_base_inputs(),\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"Maximum number of tokens to generate. Set to 0 for unlimited.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n info=\"DeepSeek model to use\",\n options=DEEPSEEK_MODELS,\n value=\"deepseek-chat\",\n refresh_button=True,\n ),\n StrInput(\n name=\"api_base\",\n display_name=\"DeepSeek API Base\",\n advanced=True,\n info=\"Base URL for API requests. Defaults to https://api.deepseek.com\",\n value=\"https://api.deepseek.com\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"DeepSeek API Key\",\n info=\"The DeepSeek API Key\",\n advanced=False,\n required=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n info=\"Controls randomness in responses\",\n value=1.0,\n range_spec=RangeSpec(min=0, max=2, step=0.01),\n advanced=True,\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n ]\n\n def get_models(self) -> list[str]:\n if not self.api_key:\n return DEEPSEEK_MODELS\n\n url = f\"{self.api_base}/models\"\n headers = {\"Authorization\": f\"Bearer {self.api_key}\", \"Accept\": \"application/json\"}\n\n try:\n response = ssrf_safe_httpx_get(url, headers=headers, timeout=10)\n response.raise_for_status()\n model_list = response.json()\n return [model[\"id\"] for model in model_list.get(\"data\", [])]\n except SSRFProtectionError as e:\n self.status = f\"SSRF Protection: {e}\"\n return DEEPSEEK_MODELS\n except httpx.HTTPError as e:\n self.status = f\"Error fetching models: {e}\"\n return DEEPSEEK_MODELS\n\n @override\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n if field_name in {\"api_key\", \"api_base\", \"model_name\"}:\n models = self.get_models()\n build_config[\"model_name\"][\"options\"] = models\n return build_config\n\n def build_model(self) -> LanguageModel:\n try:\n from langchain_openai import ChatOpenAI\n except ImportError as e:\n msg = \"langchain-openai not installed. Please install with `pip install langchain-openai`\"\n raise ImportError(msg) from e\n\n api_key = SecretStr(self.api_key).get_secret_value() if self.api_key else None\n ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(self.api_base)\n\n output = ChatOpenAI(\n model=self.model_name,\n temperature=self.temperature if self.temperature is not None else 0.1,\n max_tokens=self.max_tokens or None,\n model_kwargs=self.model_kwargs or {},\n base_url=self.api_base,\n api_key=api_key,\n streaming=self.stream if hasattr(self, \"stream\") else False,\n seed=self.seed,\n **ssrf_client_kwargs,\n )\n\n if self.json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get message from DeepSeek API exception.\"\"\"\n try:\n from openai import BadRequestError\n\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n except ImportError:\n pass\n return None\n"
},
"input_value": {
"_input_type": "MessageInput",
@@ -68705,7 +68705,7 @@
"icon": "folder",
"legacy": false,
"metadata": {
- "code_hash": "8689a573f75f",
+ "code_hash": "4d7599c7f31f",
"dependencies": {
"dependencies": [
{
@@ -68763,7 +68763,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "\"\"\"Sandboxed filesystem tool component exposing 5 file I/O tools to agents.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextvars\nimport json\nimport os\nimport re\nfrom pathlib import Path, PureWindowsPath\nfrom typing import TYPE_CHECKING\n\nfrom langchain_core.tools import StructuredTool\nfrom pydantic import BaseModel, Field\n\nfrom lfx.components.files_and_knowledge._filesystem_isolation import (\n IsolationConfig,\n load_isolation_config,\n)\nfrom lfx.components.files_and_knowledge._filesystem_namespace import (\n compute_user_namespace,\n load_or_create_pepper,\n)\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import BoolInput, StrInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\nfrom lfx.services.deps import get_settings_service\n\nif TYPE_CHECKING:\n from lfx.field_typing import Tool\n\n# Sub-directory under used when AUTO_LOGIN=True. Stays parallel to\n# users// so flipping AUTO_LOGIN at deploy-time does not mix file trees.\nSHARED_NAMESPACE = \"shared\"\n\n\n# L2 binding atomicity: the binding check captures `user_id` once at the\n# start of every tool invocation; subsequent reads of `_resolve_user_id`\n# during the same call (in _validate_root, _validate_path, etc.) read this\n# pinned value instead of re-resolving. Without this pin, a concurrent\n# mutation of `self._user_id` between the binding check and the path\n# resolution would let an attacker write into a foreign user's namespace\n# while the check still passed for the original user.\n_pinned_user_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(\n \"_filesystem_pinned_user_id\",\n default=None,\n)\n\n\n# Reserved on-disk segments inside every user namespace.\n#\n# `.lfsig` is the forward hook for an HMAC sidecar tree (L3 in the FS plan).\n# `.components` is the storage location for user-generated component code\n# (written by a privileged backend helper after Layer-2 code validation;\n# read by the registry overlay so build_flow / search_components see the\n# user's custom types). Allowing the agent's FS tools to touch either\n# directory would either poison a security-critical namespace or let the\n# agent plant arbitrary code into its own executable namespace.\n#\n# Kept as a singular constant for backwards-compat with prior message text\n# (\"Path component '.lfsig' is reserved\") and as a tuple for the actual\n# check — both names point at the same casefold set.\nRESERVED_SEGMENT = \".lfsig\"\nRESERVED_SEGMENTS: tuple[str, ...] = (\".lfsig\", \".components\")\n\n\ndef _default_config_dir() -> Path:\n \"\"\"Pick a sensible config dir when no env var is set.\n\n Operators set ``LANGFLOW_FS_TOOL_BASE_DIR`` / ``LANGFLOW_FS_TOOL_PEPPER_PATH``\n explicitly in any real deployment; this fallback exists so the OSS desktop\n install just works without any setup.\n \"\"\"\n return Path.home() / \".langflow\" / \"fs_tool\"\n\n\nMAX_FILE_SIZE_BYTES = 10 * 1024 * 1024\nBINARY_SNIFF_BYTES = 8 * 1024\nGLOB_RESULT_LIMIT = 100\n# Hard upper bound on the number of glob matches collected before truncation.\n# Without scanning past `GLOB_RESULT_LIMIT`, the first big branch hit by\n# `os.scandir` order fills the cap and entire nested branches are silently\n# dropped (BUG-02 / T2-001). The ceiling bounds memory/time on pathological\n# trees while leaving headroom to surface diverse branches to the agent.\nGLOB_SCAN_CEILING = GLOB_RESULT_LIMIT * 10\nGREP_LINE_LIMIT = 250\nGREP_OUTPUT_MODES = (\"files_with_matches\", \"content\", \"count\")\n# Hard ceiling on user-supplied regex pattern length. Long patterns are a\n# common ReDoS vector and have no legitimate need against single text lines.\nGREP_PATTERN_MAX_LEN = 1024\n# Skip regex matching on lines longer than this — exponential backtracking\n# requires non-trivial input length, and oversized lines tend to be data\n# blobs (minified JS/JSON) rather than meaningful matches.\nGREP_REGEX_LINE_MAX_LEN = 4096\n# Cap on total lines scanned per file in regex mode, regardless of matches.\n# Bounds the work even for benign-looking patterns on large files.\nGREP_REGEX_LINES_PER_FILE = 50_000\n\n# Heuristic ReDoS detector.\n#\n# Stdlib `re` has no native timeout; we cannot kill a runaway match because\n# the engine holds the GIL. Instead we reject patterns whose structure makes\n# catastrophic backtracking possible BEFORE compiling them.\n#\n# The classic ReDoS shape is a nested unbounded quantifier — a parenthesized\n# group whose body is itself quantifiable, followed by an outer `+`/`*`/`{n,}`.\n# Examples: `(a+)+`, `(a*)*`, `(.*)+`, `(\\w+)+`, `(a|aa)+`. We reject any\n# group that ends with `)+`/`)*`/`){n,}` AND whose body contains an\n# unescaped quantifier (`+`, `*`, `{n,}`) or alternation (`|`).\n#\n# This is intentionally conservative — it rules out some safe patterns\n# (`(ab)+` is fine; `(ab+)+` is not, but our rule rejects both). Users who\n# need those patterns can rewrite without the outer quantifier. Trading a\n# bit of expressiveness for a hard guarantee that no user pattern can\n# DoS the worker.\n_REDOS_GROUP = re.compile(\n r\"\"\"\n \\( # outer group open\n (?: # body of the group:\n (?:\\\\.) # - any escaped char (so \\+ etc. don't trip us)\n | [^()] # - or any non-paren char\n )*?\n (?: # ... that contains at least one of:\n (? bool:\n \"\"\"Return True if `pattern` matches a known catastrophic-backtracking shape.\"\"\"\n return bool(_REDOS_GROUP.search(pattern))\n\n\n# Windows portability rules (applied on every host OS so flows authored on\n# macOS/Linux do not silently break when run on Windows). Pure-string checks.\n_WINDOWS_RESERVED_NAMES = frozenset(\n {\"CON\", \"PRN\", \"AUX\", \"NUL\", *(f\"COM{i}\" for i in range(1, 10)), *(f\"LPT{i}\" for i in range(1, 10))}\n)\n# Note: ':' is NOT included — drive letters (C:) are valid; bare ':' inside a\n# basename will be caught by the OS at write time anyway.\n_WINDOWS_FORBIDDEN_CHARS = frozenset('<>\"|?*')\n\n# Deny-list: even when a path lies inside `root_path`, refuse access to\n# basenames or path components that match well-known credential / secret\n# patterns. This is the \"default-deny inside an allowed root\" pattern from\n# Claude Code Sandboxing — it limits the blast radius of a flow author who\n# misconfigures `root_path` to cover $HOME or the project root. Pure-string\n# checks; runs before any I/O so the agent never observes the file's\n# existence, contents, or absence-vs-denied distinction beyond the error\n# string itself.\n#\n# Match semantics (all case-insensitive):\n# * literals — exact basename match (e.g. `.env`, `.netrc`)\n# * prefixes — basename startswith (e.g. `id_rsa`, `id_rsa.pub`, `id_ed25519`)\n# * suffixes — basename endswith (e.g. `cert.pem`, `private.key`)\n# * fragments — any DIRECTORY component equals the fragment (e.g. `.ssh/`,\n# `.aws/config`); the basename itself is not matched against fragments.\n_DENY_BASENAME_LITERALS = frozenset({\".env\", \".netrc\", \".pgpass\", \".htpasswd\", \"authorized_keys\"})\n_DENY_BASENAME_PREFIXES = (\"id_rsa\", \"id_dsa\", \"id_ecdsa\", \"id_ed25519\", \"credentials\")\n_DENY_BASENAME_SUFFIXES = (\".pem\", \".key\", \".pfx\", \".p12\")\n_DENY_PATH_FRAGMENTS = frozenset({\".ssh\", \".aws\", \".gnupg\", \".docker\", \".kube\", \".git\"})\n\n\ndef _looks_binary(head: bytes) -> bool:\n return b\"\\x00\" in head\n\n\ndef _check_hardlink(candidate: Path) -> str | None:\n \"\"\"Return an error string when ``candidate`` is a multi-hardlink file.\n\n Why we refuse multi-hardlink files: an attacker with write access to a\n location outside the sandbox can pre-create an extra hardlink pointing\n at a sandbox path. Subsequent writes through the sandbox name then\n also clobber the external name, defeating the boundary. There is no\n legitimate flow that depends on a multi-link inode inside the\n sandbox, so refusing fails closed.\n\n Restricted to **regular files**: directories on POSIX always have\n ``st_nlink >= 2`` (`.`, plus one per subdirectory entry), so checking\n them would refuse every nested path. Symlinks fall through to the\n boundary check and the no-follow helpers. Non-existent paths return\n ``None`` — creation is handled by the O_NOFOLLOW write helper.\n \"\"\"\n try:\n st = os.lstat(candidate)\n except FileNotFoundError:\n return None\n except OSError as exc:\n return f\"Cannot stat path: {exc.strerror or exc}\"\n import stat as _stat_module\n\n if _stat_module.S_ISREG(st.st_mode) and st.st_nlink > 1:\n return f\"Refusing to operate on multi-hardlink file (nlink={st.st_nlink})\"\n return None\n\n\ndef _write_bytes_no_follow(target: Path, data: bytes) -> None:\n \"\"\"Write ``data`` to ``target`` without following symlinks.\n\n Uses ``O_NOFOLLOW`` on POSIX so that any symlink at ``target`` —\n including one created by a concurrent process between path\n validation and this open — raises ``ELOOP`` and fails the write\n closed. On Windows the flag is unavailable; we lstat and refuse if\n the target is already a symlink (best-effort against the non-racy\n attacker).\n\n The file is created with mode 0600 so that, even on shared hosts,\n other users cannot read it without explicit operator action.\n \"\"\"\n flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC\n if hasattr(os, \"O_NOFOLLOW\"):\n flags |= os.O_NOFOLLOW\n elif target.is_symlink(): # pragma: no cover — Windows-only branch\n msg = f\"Refusing to write through symlink: {target}\"\n raise PermissionError(msg)\n fd = os.open(str(target), flags, 0o600)\n try:\n os.write(fd, data)\n finally:\n os.close(fd)\n\n\ndef _read_bytes_no_follow(target: Path) -> bytes:\n \"\"\"Read the entire file at ``target`` without following symlinks.\n\n Same TOCTOU rationale as ``_write_bytes_no_follow``: with\n ``O_NOFOLLOW`` the open fails if a symlink was substituted between\n path validation and this read. Reads up to ``MAX_FILE_SIZE_BYTES``\n in a single syscall — the caller has already enforced the cap via\n ``stat().st_size`` checks.\n \"\"\"\n flags = os.O_RDONLY\n if hasattr(os, \"O_NOFOLLOW\"):\n flags |= os.O_NOFOLLOW\n elif target.is_symlink(): # pragma: no cover — Windows-only branch\n msg = f\"Refusing to read through symlink: {target}\"\n raise PermissionError(msg)\n fd = os.open(str(target), flags)\n try:\n chunks: list[bytes] = []\n while True:\n chunk = os.read(fd, 1 << 20) # 1 MiB per syscall\n if not chunk:\n break\n chunks.append(chunk)\n return b\"\".join(chunks)\n finally:\n os.close(fd)\n\n\ndef _read_head_no_follow(target: Path, n: int) -> bytes:\n \"\"\"Read the first ``n`` bytes of ``target`` without following symlinks.\n\n Used for the binary-content sniff before deciding whether a file is\n safe to surface as text — the same TOCTOU defence applies.\n \"\"\"\n flags = os.O_RDONLY\n if hasattr(os, \"O_NOFOLLOW\"):\n flags |= os.O_NOFOLLOW\n elif target.is_symlink(): # pragma: no cover — Windows-only branch\n msg = f\"Refusing to read through symlink: {target}\"\n raise PermissionError(msg)\n fd = os.open(str(target), flags)\n try:\n return os.read(fd, n)\n finally:\n os.close(fd)\n\n\ndef _check_windows_portability(path: str) -> str | None:\n \"\"\"Return a human-readable error if the path has Windows-portability issues.\n\n Why we run this on every host: paths that work on macOS/Linux can silently\n fail on Windows (reserved names, forbidden characters, trailing dot/space\n silently stripped by the OS). The agent should see the same structured\n error regardless of where the flow runs.\n \"\"\"\n # Use PureWindowsPath to parse separators consistently — it splits on both\n # `/` and `\\` and exposes drive markers as components ending in '\\'.\n for component in PureWindowsPath(path).parts:\n # Skip root markers ('\\\\', '/', 'C:\\\\') and relative markers ('.', '..')\n if component.endswith((\"\\\\\", \"/\")) or component in (\".\", \"..\"):\n continue\n # Reserved name (case-insensitive, with or without extension).\n stem = component.split(\".\", 1)[0].upper()\n if stem in _WINDOWS_RESERVED_NAMES:\n return f\"Path component {component!r} is a Windows reserved name\"\n # Forbidden characters in basename.\n bad = sorted(set(component) & _WINDOWS_FORBIDDEN_CHARS)\n if bad:\n return f\"Path component {component!r} contains forbidden character(s): {bad}\"\n # Trailing dot or space — Windows silently strips them.\n if component != component.rstrip(\". \"):\n return f\"Path component {component!r} has trailing dot or space (silently stripped on Windows)\"\n return None\n\n\nclass _ReadFileArgs(BaseModel):\n path: str = Field(..., description=\"Path to the file, relative to the sandbox root.\")\n offset: int | None = Field(default=None, description=\"1-based line number to start reading from.\")\n limit: int | None = Field(default=None, description=\"Maximum number of lines to return.\")\n\n\nclass _WriteFileArgs(BaseModel):\n path: str = Field(..., description=\"Path to the file, relative to the sandbox root.\")\n content: str = Field(..., description=\"Text content to write. Overwrites existing file.\")\n\n\nclass _EditFileArgs(BaseModel):\n path: str = Field(..., description=\"Path to the file, relative to the sandbox root.\")\n old_string: str = Field(..., description=\"Exact string to replace.\")\n new_string: str = Field(..., description=\"Replacement string.\")\n replace_all: bool = Field(default=False, description=\"Replace every occurrence instead of failing on ambiguity.\")\n\n\nclass _GlobSearchArgs(BaseModel):\n pattern: str = Field(..., description=\"Glob pattern, e.g. '**/*.py'.\")\n path: str | None = Field(default=None, description=\"Optional sub-directory to scope the search.\")\n\n\nclass _GrepSearchArgs(BaseModel):\n pattern: str = Field(..., description=\"Pattern to match against file contents (literal substring by default).\")\n path: str | None = Field(default=None, description=\"Optional file or directory to scope the search.\")\n glob: str | None = Field(default=None, description=\"Optional glob filter, e.g. '*.py'.\")\n case_insensitive: bool = Field(default=False, description=\"If true, the pattern is matched case-insensitively.\")\n output_mode: str = Field(\n default=\"files_with_matches\",\n description=\"One of 'files_with_matches', 'content', 'count'.\",\n )\n is_regex: bool = Field(\n default=False,\n description=(\n \"Treat `pattern` as a Python regex. Disabled by default — when enabled, \"\n \"patterns are validated against catastrophic-backtracking shapes \"\n \"(rejecting e.g. `(a+)+` or `(.*)*`) and oversized lines are skipped.\"\n ),\n )\n\n\nclass FileSystemToolComponent(Component):\n display_name = \"File System\"\n description = \"Sandboxed filesystem access for agents.\"\n icon = \"folder\"\n name = \"FileSystemTool\"\n\n # Enables the \"Tool Mode\" toggle on the node header. When ON the framework\n # calls _get_tools() and emits a Toolset output that connects to an Agent's\n # \"Tools\" handle. When OFF, the JSON metadata output is the only handle.\n add_tool_output = True\n\n inputs = [\n StrInput(\n name=\"root_path\",\n display_name=\"Workspace Sub-path\",\n required=False,\n value=\"\",\n info=(\n \"Sub-folder inside your sandboxed workspace. Leave empty to use the \"\n \"root of the workspace.\\n\\n\"\n \"Resolves under /shared// when AUTO_LOGIN=True \"\n \"(single-user / desktop) or under /users/// \"\n \"when AUTO_LOGIN=False (multi-user, per-user isolation). \"\n \"BASE_DIR is operator-controlled via LANGFLOW_FS_TOOL_BASE_DIR.\"\n ),\n ),\n BoolInput(\n name=\"read_only\",\n display_name=\"Read Only\",\n value=False,\n advanced=True,\n info=\"If true, write and edit operations are disabled.\",\n ),\n # Synthetic hidden input. Exists ONLY to make the \"Tool Mode\" toggle\n # appear on the node header — see frontend rule in\n # `src/frontend/src/CustomNodes/helpers/parameter-filtering.ts :: isHidden`\n # and the backend gate in `src/lfx/src/lfx/template/utils.py` (toggle\n # visibility = `any(input.tool_mode for input in inputs)`).\n # `show=False` keeps it hidden from the config UI; `tool_mode=True`\n # would otherwise hide a real input in tool mode (see same isHidden\n # rule), so we keep root_path / read_only WITHOUT tool_mode=True so\n # they remain user-editable when the toggle is on. _get_tools() ignores\n # this field — each StructuredTool has its own per-operation schema.\n StrInput(\n name=\"tool_mode_trigger\",\n display_name=\"\",\n show=False,\n tool_mode=True,\n required=False,\n ),\n ]\n\n outputs = [\n Output(\n display_name=\"JSON\",\n name=\"metadata\",\n method=\"build_metadata\",\n types=[\"Data\"],\n ),\n ]\n\n def build_metadata(self) -> Data:\n \"\"\"Return introspective metadata about the sandbox. No file I/O.\n\n Surfaces the live AUTO_LOGIN-driven layout so flow authors can see\n whether per-user scoping is active and where their files actually land\n without needing to grep env vars.\n \"\"\"\n registered = [\"read_file\", \"glob_search\", \"grep_search\"]\n if not self.read_only:\n registered.extend([\"write_file\", \"edit_file\"])\n\n auto_login = self._resolve_auto_login()\n user_id = self._resolve_user_id()\n if auto_login:\n mode = \"shared\"\n elif user_id:\n mode = \"isolated\"\n else:\n mode = \"refused\"\n\n # Best-effort effective_root resolution. We never raise from metadata —\n # if the policy refuses the call (AUTO_LOGIN=False without user_id,\n # unwritable BASE_DIR, etc.) we surface the reason instead of crashing\n # the node preview / Agent build pipeline.\n effective_root: str | None\n resolution_error: str | None = None\n try:\n effective_root = str(self._validate_root())\n except Exception as exc: # noqa: BLE001 — metadata must never raise\n effective_root = None\n resolution_error = str(exc) or exc.__class__.__name__\n\n return Data(\n data={\n \"root_path\": self.root_path,\n \"read_only\": bool(self.read_only),\n \"tools_registered\": registered,\n \"auto_login\": auto_login,\n \"mode\": mode,\n \"effective_root\": effective_root,\n \"resolution_error\": resolution_error,\n }\n )\n\n async def _get_tools(self) -> list[Tool]:\n \"\"\"Tool Mode entrypoint. Called by Component.to_toolkit().\"\"\"\n # Capture the bound user_id ONCE per build. Each StructuredTool closure\n # below re-checks the live ``self._user_id`` against this value at call\n # time, so a tool issued for user A cannot be invoked after the\n # component has been reused for user B (defense for L2 in the plan).\n bound_user_id = self._resolve_user_id()\n tools: list[Tool] = [\n self._make_read_tool(bound_user_id=bound_user_id),\n self._make_glob_tool(bound_user_id=bound_user_id),\n self._make_grep_tool(bound_user_id=bound_user_id),\n ]\n if not self.read_only:\n tools.append(self._make_write_tool(bound_user_id=bound_user_id))\n tools.append(self._make_edit_tool(bound_user_id=bound_user_id))\n return tools\n\n def _user_binding_error(self, bound_user_id: str | None) -> dict | None:\n \"\"\"Return a structured error dict if the live user_id has shifted.\n\n In shared mode (AUTO_LOGIN=True) user_id is not part of the security\n boundary, so the binding check is a no-op. In isolated mode we compare\n the captured user_id to the live one and refuse if they diverge —\n defends against a worker pool reusing one component instance across\n sessions for different users.\n \"\"\"\n _, err = self._user_binding_check(bound_user_id)\n return err\n\n def _user_binding_check(self, bound_user_id: str | None) -> tuple[str | None, dict | None]:\n \"\"\"Atomic capture of the user_id for the current invocation.\n\n Returns ``(captured_user_id, error_or_none)``. The captured value is\n the SINGLE read of ``_resolve_user_id`` for this call; downstream\n path-resolution code MUST reuse it (via the ContextVar pin) instead\n of reading again — otherwise a mid-call mutation of ``self._user_id``\n would let a write land in a different user's namespace while this\n check still passed for the original user.\n \"\"\"\n # When force_isolation is set, every invocation MUST carry the user_id\n # that was captured at binding time — AUTO_LOGIN does not relax the\n # check here, otherwise the per-user root in _validate_root would be\n # reached with the wrong identity.\n if getattr(self, \"_force_isolation\", False):\n current = self._resolve_user_id()\n if current and current == bound_user_id:\n return current, None\n return None, {\n \"error\": (\n \"tool/user-id mismatch: this tool was bound to a different user session and cannot be reused\"\n ),\n }\n if self._resolve_auto_login():\n return bound_user_id, None\n current = self._resolve_user_id()\n if current == bound_user_id:\n return current, None\n return None, {\n \"error\": (\"tool/user-id mismatch: this tool was bound to a different user session and cannot be reused\"),\n }\n\n def _make_read_tool(self, *, bound_user_id: str | None) -> StructuredTool:\n def _run(path: str, offset: int | None = None, limit: int | None = None) -> str:\n captured, err = self._user_binding_check(bound_user_id)\n if err is not None:\n return json.dumps(err)\n token = _pinned_user_id_var.set(captured)\n try:\n return json.dumps(self._read_file(path, offset=offset, limit=limit))\n finally:\n _pinned_user_id_var.reset(token)\n\n return StructuredTool.from_function(\n name=\"read_file\",\n description=(\n \"Read a text file from the sandboxed workspace. \"\n \"Returns content prefixed with line numbers plus metadata \"\n \"(total_lines, start_line, num_lines).\"\n ),\n func=_run,\n args_schema=_ReadFileArgs,\n tags=[\"read_file\"],\n )\n\n def _make_write_tool(self, *, bound_user_id: str | None) -> StructuredTool:\n def _run(path: str, content: str) -> str:\n captured, err = self._user_binding_check(bound_user_id)\n if err is not None:\n return json.dumps(err)\n token = _pinned_user_id_var.set(captured)\n try:\n return json.dumps(self._write_file(path, content))\n finally:\n _pinned_user_id_var.reset(token)\n\n return StructuredTool.from_function(\n name=\"write_file\",\n description=\"Create or overwrite a text file inside the sandboxed workspace.\",\n func=_run,\n args_schema=_WriteFileArgs,\n tags=[\"write_file\"],\n )\n\n def _make_edit_tool(self, *, bound_user_id: str | None) -> StructuredTool:\n def _run(path: str, old_string: str, new_string: str, *, replace_all: bool = False) -> str:\n captured, err = self._user_binding_check(bound_user_id)\n if err is not None:\n return json.dumps(err)\n token = _pinned_user_id_var.set(captured)\n try:\n return json.dumps(\n self._edit_file(path, old_string=old_string, new_string=new_string, replace_all=replace_all)\n )\n finally:\n _pinned_user_id_var.reset(token)\n\n return StructuredTool.from_function(\n name=\"edit_file\",\n description=(\n \"Edit a text file by replacing an exact old_string with new_string. \"\n \"Fails on ambiguous matches unless replace_all=True.\"\n ),\n func=_run,\n args_schema=_EditFileArgs,\n tags=[\"edit_file\"],\n )\n\n def _make_glob_tool(self, *, bound_user_id: str | None) -> StructuredTool:\n def _run(pattern: str, path: str | None = None) -> str:\n captured, err = self._user_binding_check(bound_user_id)\n if err is not None:\n return json.dumps(err)\n token = _pinned_user_id_var.set(captured)\n try:\n return json.dumps(self._glob_search(pattern, path=path))\n finally:\n _pinned_user_id_var.reset(token)\n\n return StructuredTool.from_function(\n name=\"glob_search\",\n description=(\n \"List files matching a glob pattern inside the sandboxed workspace. \"\n f\"Results are truncated at {GLOB_RESULT_LIMIT} entries.\"\n ),\n func=_run,\n args_schema=_GlobSearchArgs,\n tags=[\"glob_search\"],\n )\n\n def _make_grep_tool(self, *, bound_user_id: str | None) -> StructuredTool:\n def _run(\n pattern: str,\n path: str | None = None,\n glob: str | None = None,\n *,\n case_insensitive: bool = False,\n output_mode: str = \"files_with_matches\",\n is_regex: bool = False,\n ) -> str:\n captured, err = self._user_binding_check(bound_user_id)\n if err is not None:\n return json.dumps(err)\n token = _pinned_user_id_var.set(captured)\n try:\n return json.dumps(\n self._grep_search(\n pattern,\n path=path,\n glob=glob,\n case_insensitive=case_insensitive,\n output_mode=output_mode,\n is_regex=is_regex,\n )\n )\n finally:\n _pinned_user_id_var.reset(token)\n\n return StructuredTool.from_function(\n name=\"grep_search\",\n description=(\n \"Search file contents. Default is literal substring match (safe for any pattern); \"\n \"set is_regex=True to opt into Python regex. Patterns with nested unbounded \"\n \"quantifiers (e.g. (a+)+) are rejected to prevent catastrophic backtracking. \"\n \"output_mode: 'files_with_matches' (default), 'content', or 'count'. \"\n f\"Content mode is capped at {GREP_LINE_LIMIT} lines.\"\n ),\n func=_run,\n args_schema=_GrepSearchArgs,\n tags=[\"grep_search\"],\n )\n\n def _resolve_user_id(self) -> str | None:\n \"\"\"Best-effort lookup of the calling user's id.\n\n The Component base class populates ``_user_id`` during instantiation,\n and the property cascades to ``self.graph.user_id``. In tests there is\n no graph; in scheduled/anonymous runs there is no user_id at all. We\n treat all of those as \"anonymous\" — the isolation mode decides what to\n do with that information.\n\n Why we filter ``\"none\"`` / ``\"null\"``: Langflow's ``PlaceholderGraph``\n stringifies a missing user as ``\"None\"`` rather than the Python\n ``None`` value, so a naive truthiness check would mistake \"no user\" for\n a real user named \"None\" and create a spurious shared namespace.\n\n L2 binding atomicity: when a tool invocation is in progress, the\n pinned ContextVar value takes precedence so every read inside the\n same call sees the user_id captured at the binding check — even if\n ``self._user_id`` is mutated mid-call by a reused component instance.\n \"\"\"\n pinned = _pinned_user_id_var.get()\n if pinned is not None:\n return pinned\n candidates: list[object | None] = [getattr(self, \"_user_id\", None)]\n graph = getattr(self, \"graph\", None)\n if graph is not None:\n candidates.append(getattr(graph, \"user_id\", None))\n\n for value in candidates:\n if value is None:\n continue\n cleaned = str(value).strip()\n if not cleaned or cleaned.lower() in {\"none\", \"null\"}:\n continue\n return cleaned\n return None\n\n def _isolation_config(self) -> IsolationConfig:\n \"\"\"Read the on-disk layout config from the environment on every call.\n\n Re-read intentionally: tests and live operators tweak env vars between\n runs; caching would create stale-config bugs that are painful to\n diagnose. The cost is a handful of dict lookups per tool call.\n \"\"\"\n return load_isolation_config(env=os.environ, default_config_dir=_default_config_dir())\n\n def _resolve_auto_login(self) -> bool:\n \"\"\"Return AUTO_LOGIN from the live settings service.\n\n Defaults to True when the settings service is unavailable (tests, very\n early bootstrap) — mirrors the platform-wide default and keeps the\n component usable in lightweight contexts. Tests can override this\n method per-instance to pin the desired mode.\n \"\"\"\n try:\n settings_service = get_settings_service()\n except Exception: # noqa: BLE001 — service registry may not be ready\n return True\n if settings_service is None:\n return True\n try:\n return bool(settings_service.auth_settings.AUTO_LOGIN)\n except AttributeError:\n return True\n\n def _validate_root(self) -> Path:\n \"\"\"Resolve and authorize the effective sandbox root.\n\n Dispatch:\n - ``_force_isolation=True`` → /users//\n - AUTO_LOGIN=True → /shared/\n - AUTO_LOGIN=False + user_id → /users//\n - any mode with no user_id → PermissionError (caught by callers)\n\n ``_force_isolation`` exists for callers that carry an authenticated\n user identity and need per-user isolation regardless of the global\n AUTO_LOGIN flag (e.g. the agentic file router + the agent's write\n tools). It defaults to False so other call sites keep their current\n AUTO_LOGIN-driven behavior unchanged.\n \"\"\"\n config = self._isolation_config()\n\n if getattr(self, \"_force_isolation\", False):\n user_id = self._resolve_user_id()\n if not user_id:\n msg = \"FileSystemTool requires an authenticated user when _force_isolation is set\"\n raise PermissionError(msg)\n return self._isolated_user_root(config=config, user_id=user_id)\n\n if self._resolve_auto_login():\n return self._shared_root(config=config)\n\n user_id = self._resolve_user_id()\n if not user_id:\n msg = \"FileSystemTool requires an authenticated user when AUTO_LOGIN=False\"\n raise PermissionError(msg)\n return self._isolated_user_root(config=config, user_id=user_id)\n\n def _shared_root(self, *, config: IsolationConfig) -> Path:\n \"\"\"Materialize ``/shared/`` and verify the boundary.\n\n Why a fixed ``shared/`` prefix (and not just ``base_dir`` directly):\n keeps the on-disk layout symmetric with isolated mode (``users//``)\n so flipping AUTO_LOGIN at deploy time never mixes the two trees.\n \"\"\"\n shared_root = (config.base_dir / SHARED_NAMESPACE).resolve()\n try:\n shared_root.mkdir(parents=True, exist_ok=True)\n except OSError as exc:\n msg = (\n f\"Cannot create shared workspace at {shared_root}: \"\n f\"{exc.strerror or exc}. \"\n f\"Check that LANGFLOW_FS_TOOL_BASE_DIR ({config.base_dir}) is writable \"\n f\"by the Langflow process user.\"\n )\n raise PermissionError(msg) from exc\n\n sub_raw = (self.root_path or \"\").strip()\n sub = sub_raw.lstrip(\"/\\\\\")\n candidate = (shared_root / sub).resolve() if sub else shared_root\n if not candidate.is_relative_to(shared_root):\n msg = f\"sub_path {self.root_path!r} escapes shared workspace\"\n raise PermissionError(msg)\n try:\n candidate.mkdir(parents=True, exist_ok=True)\n except OSError as exc:\n msg = f\"Cannot create sub-path {candidate}: {exc.strerror or exc}\"\n raise PermissionError(msg) from exc\n return candidate\n\n def _isolated_user_root(self, *, config: IsolationConfig, user_id: str) -> Path:\n \"\"\"Materialize ``/users//`` and verify the boundary.\"\"\"\n try:\n pepper = load_or_create_pepper(config.pepper_path)\n except OSError as exc:\n msg = (\n f\"Cannot access pepper file at {config.pepper_path}: \"\n f\"{exc.strerror or exc}. \"\n f\"Check that LANGFLOW_FS_TOOL_PEPPER_PATH points to a writable location.\"\n )\n raise PermissionError(msg) from exc\n namespace = compute_user_namespace(user_id, pepper=pepper)\n user_root = (config.base_dir / namespace).resolve()\n try:\n user_root.mkdir(parents=True, exist_ok=True)\n except OSError as exc:\n msg = (\n f\"Cannot create user namespace at {user_root}: \"\n f\"{exc.strerror or exc}. \"\n f\"Check that LANGFLOW_FS_TOOL_BASE_DIR ({config.base_dir}) is writable \"\n f\"by the Langflow process user.\"\n )\n raise PermissionError(msg) from exc\n\n sub_raw = (self.root_path or \"\").strip()\n # Strip leading separators so absolute-looking sub_paths are pinned\n # under the user root rather than escaping to the host filesystem.\n sub = sub_raw.lstrip(\"/\\\\\")\n candidate = (user_root / sub).resolve() if sub else user_root\n if not candidate.is_relative_to(user_root):\n msg = f\"sub_path {self.root_path!r} escapes user namespace\"\n raise PermissionError(msg)\n try:\n candidate.mkdir(parents=True, exist_ok=True)\n except OSError as exc:\n msg = f\"Cannot create sub-path {candidate}: {exc.strerror or exc}\"\n raise PermissionError(msg) from exc\n return candidate\n\n def _validate_path(self, path: str) -> Path:\n \"\"\"Resolve `path` relative to the sandbox root and reject any escape.\n\n Why raise: internal helper for control flow; each tool operation catches\n PermissionError and translates it into a structured error for the agent.\n \"\"\"\n if \"\\x00\" in path:\n msg = \"Path contains NUL byte\"\n raise PermissionError(msg)\n if portability_error := _check_windows_portability(path):\n raise PermissionError(portability_error)\n # Block the reserved segments (`.lfsig` integrity hook, `.components`\n # registered-user-component store). We forbid traversal even by users\n # with valid credentials — agents and humans both — because the\n # privilege model depends on these directories being unreachable from\n # the public tool surface.\n # Compare case-insensitively: APFS and NTFS resolve `.LFSIG` to the\n # same directory as `.lfsig`, so a case-sensitive equality check\n # lets uppercase variants slip past the reservation on those\n # filesystems. `casefold` is the locale-aware lowercase used for\n # caseless string comparison — broader than `.lower()`.\n reserved_folds = {seg.casefold() for seg in RESERVED_SEGMENTS}\n for part in PureWindowsPath(path).parts:\n folded = part.casefold()\n if folded in reserved_folds:\n # Surface the canonical segment name (with leading dot) so\n # error messages stay stable across modes and OSes.\n canonical = next(seg for seg in RESERVED_SEGMENTS if seg.casefold() == folded)\n msg = f\"Path component {canonical!r} is reserved\"\n raise PermissionError(msg)\n root_resolved = self._validate_root()\n candidate = (root_resolved / path).resolve()\n if not candidate.is_relative_to(root_resolved):\n msg = f\"Path escapes workspace boundary: {path}\"\n raise PermissionError(msg)\n if hardlink_error := _check_hardlink(candidate):\n raise PermissionError(hardlink_error)\n return candidate\n\n def _read_file(self, path: str, offset: int | None = None, limit: int | None = None) -> dict:\n try:\n resolved = self._validate_path(path)\n except PermissionError as exc:\n return {\"error\": str(exc), \"path\": path}\n\n if not resolved.exists():\n return {\"error\": f\"File not found: {path}\", \"path\": path}\n if resolved.is_dir():\n return {\"error\": f\"Path is a directory, not a file: {path}\", \"path\": path}\n\n try:\n size = resolved.stat().st_size\n except OSError as exc:\n return {\"error\": f\"Cannot stat file: {exc.strerror or exc}\", \"path\": path}\n if size > MAX_FILE_SIZE_BYTES:\n return {\n \"error\": f\"File size {size} exceeds limit of {MAX_FILE_SIZE_BYTES} bytes\",\n \"path\": path,\n }\n\n try:\n head = _read_head_no_follow(resolved, BINARY_SNIFF_BYTES)\n except OSError as exc:\n return {\"error\": f\"Cannot read file: {exc.strerror or exc}\", \"path\": path}\n if _looks_binary(head):\n return {\"error\": f\"Refusing to read binary file: {path}\", \"path\": path}\n\n try:\n text = _read_bytes_no_follow(resolved).decode(\"utf-8\", errors=\"replace\")\n except OSError as exc:\n return {\"error\": f\"Cannot read file: {exc.strerror or exc}\", \"path\": path}\n\n lines = text.splitlines()\n total = len(lines)\n start = offset if offset and offset > 0 else 1\n end = total if limit is None else min(total, start - 1 + limit)\n window = lines[start - 1 : end]\n numbered = \"\\n\".join(f\"{i:>6}→{line}\" for i, line in enumerate(window, start=start))\n return {\n \"status\": \"ok\",\n \"path\": path,\n \"content\": numbered,\n \"total_lines\": total,\n \"start_line\": start,\n \"num_lines\": len(window),\n }\n\n def _write_file(self, path: str, content: str) -> dict:\n try:\n resolved = self._validate_path(path)\n except PermissionError as exc:\n return {\"error\": str(exc), \"path\": path}\n\n encoded = content.encode(\"utf-8\")\n if len(encoded) > MAX_FILE_SIZE_BYTES:\n return {\n \"error\": f\"Content size {len(encoded)} exceeds limit of {MAX_FILE_SIZE_BYTES} bytes\",\n \"path\": path,\n }\n\n if resolved.is_dir():\n return {\"error\": f\"Path is a directory, not a file: {path}\", \"path\": path}\n # Auto-create missing parent directories. `_validate_path` has already\n # confirmed `resolved` lies inside the sandbox root, so every ancestor\n # of `resolved.parent` is also inside the root — `mkdir(parents=True)`\n # cannot escape the sandbox.\n try:\n resolved.parent.mkdir(parents=True, exist_ok=True)\n except OSError as exc:\n return {\"error\": f\"Cannot create parent directory: {exc.strerror or exc}\", \"path\": path}\n\n existed = resolved.exists()\n try:\n _write_bytes_no_follow(resolved, encoded)\n except OSError as exc:\n return {\"error\": f\"Cannot write file: {exc.strerror or exc}\", \"path\": path}\n\n return {\n \"status\": \"updated\" if existed else \"created\",\n \"path\": path,\n \"bytes_written\": len(encoded),\n }\n\n def _edit_file(\n self,\n path: str,\n old_string: str,\n new_string: str,\n *,\n replace_all: bool = False,\n ) -> dict:\n try:\n resolved = self._validate_path(path)\n except PermissionError as exc:\n return {\"error\": str(exc), \"path\": path}\n\n if not resolved.exists():\n return {\"error\": f\"File not found: {path}\", \"path\": path}\n if resolved.is_dir():\n return {\"error\": f\"Path is a directory, not a file: {path}\", \"path\": path}\n\n # Reject empty old_string outright. With `old_string=\"\"`, str.replace\n # inserts new_string between every character (and at both ends),\n # producing roughly N*len(new_string) extra bytes — a small file plus\n # a large new_string can blow far past MAX_FILE_SIZE_BYTES before the\n # post-construction guard runs. Empty old_string also has no\n # well-defined semantics for \"edit this exact match\".\n if old_string == \"\":\n return {\"error\": \"old_string must not be empty\", \"path\": path}\n\n try:\n current = _read_bytes_no_follow(resolved).decode(\"utf-8\", errors=\"replace\")\n except OSError as exc:\n return {\"error\": f\"Cannot read file: {exc.strerror or exc}\", \"path\": path}\n\n occurrences = current.count(old_string)\n if occurrences == 0:\n return {\"error\": f\"old_string not found in file: {path}\", \"path\": path}\n if occurrences > 1 and not replace_all:\n return {\n \"error\": (\n f\"old_string is ambiguous: {occurrences} multiple matches in {path}. \"\n \"Pass replace_all=True to replace every occurrence.\"\n ),\n \"path\": path,\n \"matches\": occurrences,\n }\n\n # Project the encoded size BEFORE building the replacement string.\n # str.replace allocates the full result up front, so a small file\n # with replace_all=True and a large new_string can balloon memory\n # before the post-allocation length check runs. UTF-8 byte counts are\n # exact (no estimation) so the projection is precise.\n old_bytes = len(old_string.encode(\"utf-8\"))\n new_bytes = len(new_string.encode(\"utf-8\"))\n current_bytes = len(current.encode(\"utf-8\"))\n replacements_planned = occurrences if replace_all else 1\n projected_bytes = current_bytes + replacements_planned * (new_bytes - old_bytes)\n if projected_bytes > MAX_FILE_SIZE_BYTES:\n return {\n \"error\": (\n f\"Resulting content size {projected_bytes} would exceed limit of {MAX_FILE_SIZE_BYTES} bytes\"\n ),\n \"path\": path,\n }\n\n updated = current.replace(old_string, new_string) if replace_all else current.replace(old_string, new_string, 1)\n encoded = updated.encode(\"utf-8\")\n # Defensive re-check (should be unreachable given the projection above,\n # but keeps the original invariant explicit).\n if len(encoded) > MAX_FILE_SIZE_BYTES:\n return {\n \"error\": f\"Resulting content size {len(encoded)} exceeds limit of {MAX_FILE_SIZE_BYTES} bytes\",\n \"path\": path,\n }\n\n try:\n _write_bytes_no_follow(resolved, encoded)\n except OSError as exc:\n return {\"error\": f\"Cannot write file: {exc.strerror or exc}\", \"path\": path}\n\n return {\n \"status\": \"ok\",\n \"path\": path,\n \"replacements\": occurrences if replace_all else 1,\n \"old_string\": old_string,\n \"new_string\": new_string,\n }\n\n def _glob_search(self, pattern: str, path: str | None = None) -> dict:\n # Empty patterns make ``Path.glob`` raise ``ValueError`` instead of\n # returning an empty match list, which would propagate uncaught and\n # crash the agent. Surface a structured error instead.\n if not pattern or not pattern.strip():\n return {\"error\": \"Pattern must not be empty\", \"pattern\": pattern, \"path\": path}\n # Reject traversal segments in the pattern itself. Without this guard,\n # ``base.glob(\"../*\")`` walks one directory up; one of the resulting\n # paths (``/../``) resolves back to ``base`` and\n # is surfaced to the agent as ``\".\"`` — a silent, misleading hit.\n # ``PureWindowsPath`` splits on both ``/`` and ``\\`` so the check\n # covers Windows-authored patterns as well.\n if any(part == \"..\" for part in PureWindowsPath(pattern).parts):\n return {\n \"error\": \"Pattern must not contain '..' (path traversal not allowed)\",\n \"pattern\": pattern,\n \"path\": path,\n }\n try:\n root_resolved = self._validate_root()\n base = self._validate_path(path) if path else root_resolved\n except PermissionError as exc:\n return {\"error\": str(exc), \"pattern\": pattern, \"path\": path}\n\n if not base.exists() or not base.is_dir():\n return {\"error\": f\"Base path is not a directory: {path or '.'}\", \"pattern\": pattern}\n\n # Collect up to GLOB_SCAN_CEILING (>> GLOB_RESULT_LIMIT) so we can\n # surface diverse branches even when one directory dominates the\n # iteration order. See BUG-02 / T2-001.\n collected: list[str] = []\n for match in base.glob(pattern):\n try:\n resolved_match = match.resolve()\n except OSError:\n continue\n if not resolved_match.is_relative_to(root_resolved):\n continue\n collected.append(str(resolved_match.relative_to(root_resolved)))\n if len(collected) >= GLOB_SCAN_CEILING:\n break\n\n # Sort for cross-filesystem determinism, then truncate to the visible\n # cap. Emit `truncated_branches` so the agent has a non-silent signal:\n # top-level branches under `base` whose files appear in the omitted\n # tail. The agent can narrow with `path=` to recover them.\n collected.sort()\n truncated = len(collected) > GLOB_RESULT_LIMIT\n if truncated:\n omitted = collected[GLOB_RESULT_LIMIT:]\n matches = collected[:GLOB_RESULT_LIMIT]\n truncated_branches = sorted({Path(p).parts[0] for p in omitted if Path(p).parts})\n else:\n matches = collected\n truncated_branches = []\n\n return {\n \"status\": \"ok\",\n \"pattern\": pattern,\n \"matches\": matches,\n \"truncated\": truncated,\n \"truncated_branches\": truncated_branches,\n }\n\n def _grep_search(\n self,\n pattern: str,\n path: str | None = None,\n glob: str | None = None,\n *,\n case_insensitive: bool = False,\n output_mode: str = \"files_with_matches\",\n is_regex: bool = False,\n ) -> dict:\n if output_mode not in GREP_OUTPUT_MODES:\n return {\n \"error\": f\"Invalid output_mode '{output_mode}'. Must be one of {GREP_OUTPUT_MODES}\",\n \"pattern\": pattern,\n }\n\n # Build a per-line matcher. Default is a literal substring check —\n # safe for any user-supplied pattern. Regex is opt-in and is gated by\n # a static heuristic plus per-line/per-file caps. Stdlib `re` cannot\n # be cancelled (it holds the GIL), so prevention is the only viable\n # mitigation: reject pathological patterns at compile time and bound\n # the input we hand to the engine.\n if is_regex:\n if len(pattern) > GREP_PATTERN_MAX_LEN:\n return {\n \"error\": (\n f\"Regex pattern exceeds {GREP_PATTERN_MAX_LEN} chars; \"\n \"shorten it or use literal mode (is_regex=False).\"\n ),\n \"pattern\": pattern,\n }\n if _looks_like_redos(pattern):\n return {\n \"error\": (\n \"Regex pattern rejected: nested unbounded quantifier \"\n \"(catastrophic-backtracking risk). Rewrite without an \"\n \"outer +/* on a group that already contains +, *, {n,}, \"\n \"or |, or use literal mode (is_regex=False).\"\n ),\n \"pattern\": pattern,\n }\n try:\n flags = re.IGNORECASE if case_insensitive else 0\n regex = re.compile(pattern, flags)\n except re.error as exc:\n return {\"error\": f\"Invalid regex pattern: {exc}\", \"pattern\": pattern}\n\n def line_matches(line: str) -> bool:\n # Skip regex matching on oversized lines — they are usually\n # data blobs, and a long input is a prerequisite for most\n # practical exponential-backtracking attacks.\n if len(line) > GREP_REGEX_LINE_MAX_LEN:\n return False\n return regex.search(line) is not None\n else:\n needle = pattern.lower() if case_insensitive else pattern\n\n def line_matches(line: str) -> bool:\n hay = line.lower() if case_insensitive else line\n return needle in hay\n\n try:\n root_resolved = self._validate_root()\n base = self._validate_path(path) if path else root_resolved\n except PermissionError as exc:\n return {\"error\": str(exc), \"pattern\": pattern, \"path\": path}\n\n if not base.exists():\n return {\"error\": f\"Path does not exist: {path or '.'}\", \"pattern\": pattern}\n\n targets = self._collect_grep_targets(base, glob)\n files_with_matches: list[str] = []\n content_matches: list[dict] = []\n count_matches: list[dict] = []\n line_budget = GREP_LINE_LIMIT\n truncated = False\n\n for file in targets:\n try:\n resolved_file = file.resolve()\n except OSError:\n continue\n if not resolved_file.is_relative_to(root_resolved):\n continue\n try:\n size = resolved_file.stat().st_size\n except OSError:\n continue\n if size > MAX_FILE_SIZE_BYTES:\n continue\n try:\n with resolved_file.open(\"rb\") as fh:\n head = fh.read(BINARY_SNIFF_BYTES)\n except OSError:\n continue\n if _looks_binary(head):\n continue\n try:\n text = resolved_file.read_text(encoding=\"utf-8\", errors=\"replace\")\n except OSError:\n continue\n\n rel_path = str(resolved_file.relative_to(root_resolved))\n per_file_count = 0\n matched_any = False\n for idx, line in enumerate(text.splitlines(), start=1):\n # Per-file scan cap (regex only — literal scans are O(n)).\n if is_regex and idx > GREP_REGEX_LINES_PER_FILE:\n break\n if not line_matches(line):\n continue\n matched_any = True\n per_file_count += 1\n if output_mode == \"content\":\n if line_budget <= 0:\n truncated = True\n break\n content_matches.append({\"path\": rel_path, \"line_number\": idx, \"line\": line})\n line_budget -= 1\n\n if matched_any:\n files_with_matches.append(rel_path)\n if output_mode == \"count\":\n count_matches.append({\"path\": rel_path, \"count\": per_file_count})\n\n if output_mode == \"content\" and truncated:\n break\n\n if output_mode == \"files_with_matches\":\n matches: list = files_with_matches\n elif output_mode == \"content\":\n matches = content_matches\n else: # count\n matches = count_matches\n\n return {\n \"status\": \"ok\",\n \"pattern\": pattern,\n \"output_mode\": output_mode,\n \"matches\": matches,\n \"truncated\": truncated,\n }\n\n def _collect_grep_targets(self, base: Path, glob: str | None) -> list[Path]:\n if base.is_file():\n return [base]\n all_files = [p for p in base.rglob(\"*\") if p.is_file()]\n if glob:\n return [p for p in all_files if p.match(glob)]\n return all_files\n"
+ "value": "\"\"\"Sandboxed filesystem tool component exposing 5 file I/O tools to agents.\"\"\"\n\nfrom __future__ import annotations\n\nimport contextvars\nimport json\nimport os\nimport re\nfrom pathlib import Path, PureWindowsPath\nfrom typing import TYPE_CHECKING\n\nfrom langchain_core.tools import StructuredTool\nfrom pydantic import BaseModel, Field\n\nfrom lfx.components.files_and_knowledge._filesystem_isolation import (\n IsolationConfig,\n load_isolation_config,\n)\nfrom lfx.components.files_and_knowledge._filesystem_namespace import (\n compute_user_namespace,\n load_or_create_pepper,\n)\nfrom lfx.custom.custom_component.component import Component\nfrom lfx.inputs.inputs import BoolInput, StrInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\nfrom lfx.services.deps import get_settings_service\n\nif TYPE_CHECKING:\n from lfx.field_typing import Tool\n\n# Sub-directory under used when AUTO_LOGIN=True. Stays parallel to\n# users// so flipping AUTO_LOGIN at deploy-time does not mix file trees.\nSHARED_NAMESPACE = \"shared\"\n\n\n# L2 binding atomicity: the binding check captures `user_id` once at the\n# start of every tool invocation; subsequent reads of `_resolve_user_id`\n# during the same call (in _validate_root, _validate_path, etc.) read this\n# pinned value instead of re-resolving. Without this pin, a concurrent\n# mutation of `self._user_id` between the binding check and the path\n# resolution would let an attacker write into a foreign user's namespace\n# while the check still passed for the original user.\n_pinned_user_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(\n \"_filesystem_pinned_user_id\",\n default=None,\n)\n\n\n# Reserved on-disk segments inside every user namespace.\n#\n# `.lfsig` is the forward hook for an HMAC sidecar tree (L3 in the FS plan).\n# `.components` is the storage location for user-generated component code\n# (written by a privileged backend helper after Layer-2 code validation;\n# read by the registry overlay so build_flow / search_components see the\n# user's custom types). Allowing the agent's FS tools to touch either\n# directory would either poison a security-critical namespace or let the\n# agent plant arbitrary code into its own executable namespace.\n#\n# Kept as a singular constant for backwards-compat with prior message text\n# (\"Path component '.lfsig' is reserved\") and as a tuple for the actual\n# check — both names point at the same casefold set.\nRESERVED_SEGMENT = \".lfsig\"\nRESERVED_SEGMENTS: tuple[str, ...] = (\".lfsig\", \".components\")\n\n\ndef _default_config_dir() -> Path:\n \"\"\"Pick a sensible config dir when no env var is set.\n\n Operators set ``LANGFLOW_FS_TOOL_BASE_DIR`` / ``LANGFLOW_FS_TOOL_PEPPER_PATH``\n explicitly in any real deployment; this fallback exists so the OSS desktop\n install just works without any setup.\n \"\"\"\n return Path.home() / \".langflow\" / \"fs_tool\"\n\n\nMAX_FILE_SIZE_BYTES = 10 * 1024 * 1024\nBINARY_SNIFF_BYTES = 8 * 1024\nGLOB_RESULT_LIMIT = 100\n# Hard upper bound on the number of glob matches collected before truncation.\n# Without scanning past `GLOB_RESULT_LIMIT`, the first big branch hit by\n# `os.scandir` order fills the cap and entire nested branches are silently\n# dropped (BUG-02 / T2-001). The ceiling bounds memory/time on pathological\n# trees while leaving headroom to surface diverse branches to the agent.\nGLOB_SCAN_CEILING = GLOB_RESULT_LIMIT * 10\nGREP_LINE_LIMIT = 250\nGREP_OUTPUT_MODES = (\"files_with_matches\", \"content\", \"count\")\n# Hard ceiling on user-supplied regex pattern length. Long patterns are a\n# common ReDoS vector and have no legitimate need against single text lines.\nGREP_PATTERN_MAX_LEN = 1024\n# Skip regex matching on lines longer than this — exponential backtracking\n# requires non-trivial input length, and oversized lines tend to be data\n# blobs (minified JS/JSON) rather than meaningful matches.\nGREP_REGEX_LINE_MAX_LEN = 4096\n# Cap on total lines scanned per file in regex mode, regardless of matches.\n# Bounds the work even for benign-looking patterns on large files.\nGREP_REGEX_LINES_PER_FILE = 50_000\n\n# Heuristic ReDoS detector.\n#\n# Stdlib `re` has no native timeout; we cannot kill a runaway match because\n# the engine holds the GIL. Instead we reject patterns whose structure makes\n# catastrophic backtracking possible BEFORE compiling them.\n#\n# The classic ReDoS shape is a nested unbounded quantifier — a parenthesized\n# group whose body is itself quantifiable, followed by an outer `+`/`*`/`{n,}`.\n# Examples: `(a+)+`, `(a*)*`, `(.*)+`, `(\\w+)+`, `(a|aa)+`. We reject any\n# group that ends with `)+`/`)*`/`){n,}` AND whose body contains an\n# unescaped quantifier (`+`, `*`, `{n,}`) or alternation (`|`).\n#\n# This is intentionally conservative — it rules out some safe patterns\n# (`(ab)+` is fine; `(ab+)+` is not, but our rule rejects both). Users who\n# need those patterns can rewrite without the outer quantifier. Trading a\n# bit of expressiveness for a hard guarantee that no user pattern can\n# DoS the worker.\n_REDOS_GROUP = re.compile(\n r\"\"\"\n \\( # outer group open\n (?: # body of the group:\n (?:\\\\.) # - any escaped char (so \\+ etc. don't trip us)\n | [^()] # - or any non-paren char\n )*?\n (?: # ... that contains at least one of:\n (? bool:\n \"\"\"Return True if `pattern` matches a known catastrophic-backtracking shape.\"\"\"\n return bool(_REDOS_GROUP.search(pattern))\n\n\n# Windows portability rules (applied on every host OS so flows authored on\n# macOS/Linux do not silently break when run on Windows). Pure-string checks.\n_WINDOWS_RESERVED_NAMES = frozenset(\n {\"CON\", \"PRN\", \"AUX\", \"NUL\", *(f\"COM{i}\" for i in range(1, 10)), *(f\"LPT{i}\" for i in range(1, 10))}\n)\n# Note: ':' is NOT included — drive letters (C:) are valid; bare ':' inside a\n# basename will be caught by the OS at write time anyway.\n_WINDOWS_FORBIDDEN_CHARS = frozenset('<>\"|?*')\n\n# Deny-list: even when a path lies inside `root_path`, refuse access to\n# basenames or path components that match well-known credential / secret\n# patterns. This is the \"default-deny inside an allowed root\" pattern from\n# Claude Code Sandboxing — it limits the blast radius of a flow author who\n# misconfigures `root_path` to cover $HOME or the project root. Pure-string\n# checks; runs before any I/O so the agent never observes the file's\n# existence, contents, or absence-vs-denied distinction beyond the error\n# string itself.\n#\n# Match semantics (all case-insensitive):\n# * literals — exact basename match (e.g. `.env`, `.netrc`)\n# * prefixes — basename startswith (e.g. `id_rsa`, `id_rsa.pub`, `id_ed25519`)\n# * suffixes — basename endswith (e.g. `cert.pem`, `private.key`)\n# * fragments — any DIRECTORY component equals the fragment (e.g. `.ssh/`,\n# `.aws/config`); the basename itself is not matched against fragments.\n_DENY_BASENAME_LITERALS = frozenset({\".env\", \".netrc\", \".pgpass\", \".htpasswd\", \"authorized_keys\"})\n_DENY_BASENAME_PREFIXES = (\"id_rsa\", \"id_dsa\", \"id_ecdsa\", \"id_ed25519\", \"credentials\")\n_DENY_BASENAME_SUFFIXES = (\".pem\", \".key\", \".pfx\", \".p12\")\n_DENY_PATH_FRAGMENTS = frozenset({\".ssh\", \".aws\", \".gnupg\", \".docker\", \".kube\", \".git\"})\n\n\ndef _check_deny_list(path: str) -> str | None:\n parts = PureWindowsPath(path).parts\n if not parts:\n return None\n *directories, basename = parts\n folded = basename.casefold()\n if (\n folded in _DENY_BASENAME_LITERALS\n or folded.startswith(_DENY_BASENAME_PREFIXES)\n or folded.endswith(_DENY_BASENAME_SUFFIXES)\n ):\n return f\"Access to {basename!r} is denied: filename matches a protected credential pattern\"\n for segment in directories:\n if segment.casefold() in _DENY_PATH_FRAGMENTS:\n return f\"Access to {path!r} is denied: path contains protected directory {segment!r}\"\n return None\n\n\ndef _looks_binary(head: bytes) -> bool:\n return b\"\\x00\" in head\n\n\ndef _check_hardlink(candidate: Path) -> str | None:\n \"\"\"Return an error string when ``candidate`` is a multi-hardlink file.\n\n Why we refuse multi-hardlink files: an attacker with write access to a\n location outside the sandbox can pre-create an extra hardlink pointing\n at a sandbox path. Subsequent writes through the sandbox name then\n also clobber the external name, defeating the boundary. There is no\n legitimate flow that depends on a multi-link inode inside the\n sandbox, so refusing fails closed.\n\n Restricted to **regular files**: directories on POSIX always have\n ``st_nlink >= 2`` (`.`, plus one per subdirectory entry), so checking\n them would refuse every nested path. Symlinks fall through to the\n boundary check and the no-follow helpers. Non-existent paths return\n ``None`` — creation is handled by the O_NOFOLLOW write helper.\n \"\"\"\n try:\n st = os.lstat(candidate)\n except FileNotFoundError:\n return None\n except OSError as exc:\n return f\"Cannot stat path: {exc.strerror or exc}\"\n import stat as _stat_module\n\n if _stat_module.S_ISREG(st.st_mode) and st.st_nlink > 1:\n return f\"Refusing to operate on multi-hardlink file (nlink={st.st_nlink})\"\n return None\n\n\ndef _write_bytes_no_follow(target: Path, data: bytes) -> None:\n \"\"\"Write ``data`` to ``target`` without following symlinks.\n\n Uses ``O_NOFOLLOW`` on POSIX so that any symlink at ``target`` —\n including one created by a concurrent process between path\n validation and this open — raises ``ELOOP`` and fails the write\n closed. On Windows the flag is unavailable; we lstat and refuse if\n the target is already a symlink (best-effort against the non-racy\n attacker).\n\n The file is created with mode 0600 so that, even on shared hosts,\n other users cannot read it without explicit operator action.\n \"\"\"\n flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC\n if hasattr(os, \"O_NOFOLLOW\"):\n flags |= os.O_NOFOLLOW\n elif target.is_symlink(): # pragma: no cover — Windows-only branch\n msg = f\"Refusing to write through symlink: {target}\"\n raise PermissionError(msg)\n fd = os.open(str(target), flags, 0o600)\n try:\n os.write(fd, data)\n finally:\n os.close(fd)\n\n\ndef _read_bytes_no_follow(target: Path) -> bytes:\n \"\"\"Read the entire file at ``target`` without following symlinks.\n\n Same TOCTOU rationale as ``_write_bytes_no_follow``: with\n ``O_NOFOLLOW`` the open fails if a symlink was substituted between\n path validation and this read. Reads up to ``MAX_FILE_SIZE_BYTES``\n in a single syscall — the caller has already enforced the cap via\n ``stat().st_size`` checks.\n \"\"\"\n flags = os.O_RDONLY\n if hasattr(os, \"O_NOFOLLOW\"):\n flags |= os.O_NOFOLLOW\n elif target.is_symlink(): # pragma: no cover — Windows-only branch\n msg = f\"Refusing to read through symlink: {target}\"\n raise PermissionError(msg)\n fd = os.open(str(target), flags)\n try:\n chunks: list[bytes] = []\n while True:\n chunk = os.read(fd, 1 << 20) # 1 MiB per syscall\n if not chunk:\n break\n chunks.append(chunk)\n return b\"\".join(chunks)\n finally:\n os.close(fd)\n\n\ndef _read_head_no_follow(target: Path, n: int) -> bytes:\n \"\"\"Read the first ``n`` bytes of ``target`` without following symlinks.\n\n Used for the binary-content sniff before deciding whether a file is\n safe to surface as text — the same TOCTOU defence applies.\n \"\"\"\n flags = os.O_RDONLY\n if hasattr(os, \"O_NOFOLLOW\"):\n flags |= os.O_NOFOLLOW\n elif target.is_symlink(): # pragma: no cover — Windows-only branch\n msg = f\"Refusing to read through symlink: {target}\"\n raise PermissionError(msg)\n fd = os.open(str(target), flags)\n try:\n return os.read(fd, n)\n finally:\n os.close(fd)\n\n\ndef _check_windows_portability(path: str) -> str | None:\n \"\"\"Return a human-readable error if the path has Windows-portability issues.\n\n Why we run this on every host: paths that work on macOS/Linux can silently\n fail on Windows (reserved names, forbidden characters, trailing dot/space\n silently stripped by the OS). The agent should see the same structured\n error regardless of where the flow runs.\n \"\"\"\n # Use PureWindowsPath to parse separators consistently — it splits on both\n # `/` and `\\` and exposes drive markers as components ending in '\\'.\n for component in PureWindowsPath(path).parts:\n # Skip root markers ('\\\\', '/', 'C:\\\\') and relative markers ('.', '..')\n if component.endswith((\"\\\\\", \"/\")) or component in (\".\", \"..\"):\n continue\n # Reserved name (case-insensitive, with or without extension).\n stem = component.split(\".\", 1)[0].upper()\n if stem in _WINDOWS_RESERVED_NAMES:\n return f\"Path component {component!r} is a Windows reserved name\"\n # Forbidden characters in basename.\n bad = sorted(set(component) & _WINDOWS_FORBIDDEN_CHARS)\n if bad:\n return f\"Path component {component!r} contains forbidden character(s): {bad}\"\n # Trailing dot or space — Windows silently strips them.\n if component != component.rstrip(\". \"):\n return f\"Path component {component!r} has trailing dot or space (silently stripped on Windows)\"\n return None\n\n\nclass _ReadFileArgs(BaseModel):\n path: str = Field(..., description=\"Path to the file, relative to the sandbox root.\")\n offset: int | None = Field(default=None, description=\"1-based line number to start reading from.\")\n limit: int | None = Field(default=None, description=\"Maximum number of lines to return.\")\n\n\nclass _WriteFileArgs(BaseModel):\n path: str = Field(..., description=\"Path to the file, relative to the sandbox root.\")\n content: str = Field(..., description=\"Text content to write. Overwrites existing file.\")\n\n\nclass _EditFileArgs(BaseModel):\n path: str = Field(..., description=\"Path to the file, relative to the sandbox root.\")\n old_string: str = Field(..., description=\"Exact string to replace.\")\n new_string: str = Field(..., description=\"Replacement string.\")\n replace_all: bool = Field(default=False, description=\"Replace every occurrence instead of failing on ambiguity.\")\n\n\nclass _GlobSearchArgs(BaseModel):\n pattern: str = Field(..., description=\"Glob pattern, e.g. '**/*.py'.\")\n path: str | None = Field(default=None, description=\"Optional sub-directory to scope the search.\")\n\n\nclass _GrepSearchArgs(BaseModel):\n pattern: str = Field(..., description=\"Pattern to match against file contents (literal substring by default).\")\n path: str | None = Field(default=None, description=\"Optional file or directory to scope the search.\")\n glob: str | None = Field(default=None, description=\"Optional glob filter, e.g. '*.py'.\")\n case_insensitive: bool = Field(default=False, description=\"If true, the pattern is matched case-insensitively.\")\n output_mode: str = Field(\n default=\"files_with_matches\",\n description=\"One of 'files_with_matches', 'content', 'count'.\",\n )\n is_regex: bool = Field(\n default=False,\n description=(\n \"Treat `pattern` as a Python regex. Disabled by default — when enabled, \"\n \"patterns are validated against catastrophic-backtracking shapes \"\n \"(rejecting e.g. `(a+)+` or `(.*)*`) and oversized lines are skipped.\"\n ),\n )\n\n\nclass FileSystemToolComponent(Component):\n display_name = \"File System\"\n description = \"Sandboxed filesystem access for agents.\"\n icon = \"folder\"\n name = \"FileSystemTool\"\n\n # Enables the \"Tool Mode\" toggle on the node header. When ON the framework\n # calls _get_tools() and emits a Toolset output that connects to an Agent's\n # \"Tools\" handle. When OFF, the JSON metadata output is the only handle.\n add_tool_output = True\n\n inputs = [\n StrInput(\n name=\"root_path\",\n display_name=\"Workspace Sub-path\",\n required=False,\n value=\"\",\n info=(\n \"Sub-folder inside your sandboxed workspace. Leave empty to use the \"\n \"root of the workspace.\\n\\n\"\n \"Resolves under /shared// when AUTO_LOGIN=True \"\n \"(single-user / desktop) or under /users/// \"\n \"when AUTO_LOGIN=False (multi-user, per-user isolation). \"\n \"BASE_DIR is operator-controlled via LANGFLOW_FS_TOOL_BASE_DIR.\"\n ),\n ),\n BoolInput(\n name=\"read_only\",\n display_name=\"Read Only\",\n value=False,\n advanced=True,\n info=\"If true, write and edit operations are disabled.\",\n ),\n # Synthetic hidden input. Exists ONLY to make the \"Tool Mode\" toggle\n # appear on the node header — see frontend rule in\n # `src/frontend/src/CustomNodes/helpers/parameter-filtering.ts :: isHidden`\n # and the backend gate in `src/lfx/src/lfx/template/utils.py` (toggle\n # visibility = `any(input.tool_mode for input in inputs)`).\n # `show=False` keeps it hidden from the config UI; `tool_mode=True`\n # would otherwise hide a real input in tool mode (see same isHidden\n # rule), so we keep root_path / read_only WITHOUT tool_mode=True so\n # they remain user-editable when the toggle is on. _get_tools() ignores\n # this field — each StructuredTool has its own per-operation schema.\n StrInput(\n name=\"tool_mode_trigger\",\n display_name=\"\",\n show=False,\n tool_mode=True,\n required=False,\n ),\n ]\n\n outputs = [\n Output(\n display_name=\"JSON\",\n name=\"metadata\",\n method=\"build_metadata\",\n types=[\"Data\"],\n ),\n ]\n\n def build_metadata(self) -> Data:\n \"\"\"Return introspective metadata about the sandbox. No file I/O.\n\n Surfaces the live AUTO_LOGIN-driven layout so flow authors can see\n whether per-user scoping is active and where their files actually land\n without needing to grep env vars.\n \"\"\"\n registered = [\"read_file\", \"glob_search\", \"grep_search\"]\n if not self.read_only:\n registered.extend([\"write_file\", \"edit_file\"])\n\n auto_login = self._resolve_auto_login()\n user_id = self._resolve_user_id()\n if auto_login:\n mode = \"shared\"\n elif user_id:\n mode = \"isolated\"\n else:\n mode = \"refused\"\n\n # Best-effort effective_root resolution. We never raise from metadata —\n # if the policy refuses the call (AUTO_LOGIN=False without user_id,\n # unwritable BASE_DIR, etc.) we surface the reason instead of crashing\n # the node preview / Agent build pipeline.\n effective_root: str | None\n resolution_error: str | None = None\n try:\n effective_root = str(self._validate_root())\n except Exception as exc: # noqa: BLE001 — metadata must never raise\n effective_root = None\n resolution_error = str(exc) or exc.__class__.__name__\n\n return Data(\n data={\n \"root_path\": self.root_path,\n \"read_only\": bool(self.read_only),\n \"tools_registered\": registered,\n \"auto_login\": auto_login,\n \"mode\": mode,\n \"effective_root\": effective_root,\n \"resolution_error\": resolution_error,\n }\n )\n\n async def _get_tools(self) -> list[Tool]:\n \"\"\"Tool Mode entrypoint. Called by Component.to_toolkit().\"\"\"\n # Capture the bound user_id ONCE per build. Each StructuredTool closure\n # below re-checks the live ``self._user_id`` against this value at call\n # time, so a tool issued for user A cannot be invoked after the\n # component has been reused for user B (defense for L2 in the plan).\n bound_user_id = self._resolve_user_id()\n tools: list[Tool] = [\n self._make_read_tool(bound_user_id=bound_user_id),\n self._make_glob_tool(bound_user_id=bound_user_id),\n self._make_grep_tool(bound_user_id=bound_user_id),\n ]\n if not self.read_only:\n tools.append(self._make_write_tool(bound_user_id=bound_user_id))\n tools.append(self._make_edit_tool(bound_user_id=bound_user_id))\n return tools\n\n def _user_binding_error(self, bound_user_id: str | None) -> dict | None:\n \"\"\"Return a structured error dict if the live user_id has shifted.\n\n In shared mode (AUTO_LOGIN=True) user_id is not part of the security\n boundary, so the binding check is a no-op. In isolated mode we compare\n the captured user_id to the live one and refuse if they diverge —\n defends against a worker pool reusing one component instance across\n sessions for different users.\n \"\"\"\n _, err = self._user_binding_check(bound_user_id)\n return err\n\n def _user_binding_check(self, bound_user_id: str | None) -> tuple[str | None, dict | None]:\n \"\"\"Atomic capture of the user_id for the current invocation.\n\n Returns ``(captured_user_id, error_or_none)``. The captured value is\n the SINGLE read of ``_resolve_user_id`` for this call; downstream\n path-resolution code MUST reuse it (via the ContextVar pin) instead\n of reading again — otherwise a mid-call mutation of ``self._user_id``\n would let a write land in a different user's namespace while this\n check still passed for the original user.\n \"\"\"\n # When force_isolation is set, every invocation MUST carry the user_id\n # that was captured at binding time — AUTO_LOGIN does not relax the\n # check here, otherwise the per-user root in _validate_root would be\n # reached with the wrong identity.\n if getattr(self, \"_force_isolation\", False):\n current = self._resolve_user_id()\n if current and current == bound_user_id:\n return current, None\n return None, {\n \"error\": (\n \"tool/user-id mismatch: this tool was bound to a different user session and cannot be reused\"\n ),\n }\n if self._resolve_auto_login():\n return bound_user_id, None\n current = self._resolve_user_id()\n if current == bound_user_id:\n return current, None\n return None, {\n \"error\": (\"tool/user-id mismatch: this tool was bound to a different user session and cannot be reused\"),\n }\n\n def _make_read_tool(self, *, bound_user_id: str | None) -> StructuredTool:\n def _run(path: str, offset: int | None = None, limit: int | None = None) -> str:\n captured, err = self._user_binding_check(bound_user_id)\n if err is not None:\n return json.dumps(err)\n token = _pinned_user_id_var.set(captured)\n try:\n return json.dumps(self._read_file(path, offset=offset, limit=limit))\n finally:\n _pinned_user_id_var.reset(token)\n\n return StructuredTool.from_function(\n name=\"read_file\",\n description=(\n \"Read a text file from the sandboxed workspace. \"\n \"Returns content prefixed with line numbers plus metadata \"\n \"(total_lines, start_line, num_lines).\"\n ),\n func=_run,\n args_schema=_ReadFileArgs,\n tags=[\"read_file\"],\n )\n\n def _make_write_tool(self, *, bound_user_id: str | None) -> StructuredTool:\n def _run(path: str, content: str) -> str:\n captured, err = self._user_binding_check(bound_user_id)\n if err is not None:\n return json.dumps(err)\n token = _pinned_user_id_var.set(captured)\n try:\n return json.dumps(self._write_file(path, content))\n finally:\n _pinned_user_id_var.reset(token)\n\n return StructuredTool.from_function(\n name=\"write_file\",\n description=\"Create or overwrite a text file inside the sandboxed workspace.\",\n func=_run,\n args_schema=_WriteFileArgs,\n tags=[\"write_file\"],\n )\n\n def _make_edit_tool(self, *, bound_user_id: str | None) -> StructuredTool:\n def _run(path: str, old_string: str, new_string: str, *, replace_all: bool = False) -> str:\n captured, err = self._user_binding_check(bound_user_id)\n if err is not None:\n return json.dumps(err)\n token = _pinned_user_id_var.set(captured)\n try:\n return json.dumps(\n self._edit_file(path, old_string=old_string, new_string=new_string, replace_all=replace_all)\n )\n finally:\n _pinned_user_id_var.reset(token)\n\n return StructuredTool.from_function(\n name=\"edit_file\",\n description=(\n \"Edit a text file by replacing an exact old_string with new_string. \"\n \"Fails on ambiguous matches unless replace_all=True.\"\n ),\n func=_run,\n args_schema=_EditFileArgs,\n tags=[\"edit_file\"],\n )\n\n def _make_glob_tool(self, *, bound_user_id: str | None) -> StructuredTool:\n def _run(pattern: str, path: str | None = None) -> str:\n captured, err = self._user_binding_check(bound_user_id)\n if err is not None:\n return json.dumps(err)\n token = _pinned_user_id_var.set(captured)\n try:\n return json.dumps(self._glob_search(pattern, path=path))\n finally:\n _pinned_user_id_var.reset(token)\n\n return StructuredTool.from_function(\n name=\"glob_search\",\n description=(\n \"List files matching a glob pattern inside the sandboxed workspace. \"\n f\"Results are truncated at {GLOB_RESULT_LIMIT} entries.\"\n ),\n func=_run,\n args_schema=_GlobSearchArgs,\n tags=[\"glob_search\"],\n )\n\n def _make_grep_tool(self, *, bound_user_id: str | None) -> StructuredTool:\n def _run(\n pattern: str,\n path: str | None = None,\n glob: str | None = None,\n *,\n case_insensitive: bool = False,\n output_mode: str = \"files_with_matches\",\n is_regex: bool = False,\n ) -> str:\n captured, err = self._user_binding_check(bound_user_id)\n if err is not None:\n return json.dumps(err)\n token = _pinned_user_id_var.set(captured)\n try:\n return json.dumps(\n self._grep_search(\n pattern,\n path=path,\n glob=glob,\n case_insensitive=case_insensitive,\n output_mode=output_mode,\n is_regex=is_regex,\n )\n )\n finally:\n _pinned_user_id_var.reset(token)\n\n return StructuredTool.from_function(\n name=\"grep_search\",\n description=(\n \"Search file contents. Default is literal substring match (safe for any pattern); \"\n \"set is_regex=True to opt into Python regex. Patterns with nested unbounded \"\n \"quantifiers (e.g. (a+)+) are rejected to prevent catastrophic backtracking. \"\n \"output_mode: 'files_with_matches' (default), 'content', or 'count'. \"\n f\"Content mode is capped at {GREP_LINE_LIMIT} lines.\"\n ),\n func=_run,\n args_schema=_GrepSearchArgs,\n tags=[\"grep_search\"],\n )\n\n def _resolve_user_id(self) -> str | None:\n \"\"\"Best-effort lookup of the calling user's id.\n\n The Component base class populates ``_user_id`` during instantiation,\n and the property cascades to ``self.graph.user_id``. In tests there is\n no graph; in scheduled/anonymous runs there is no user_id at all. We\n treat all of those as \"anonymous\" — the isolation mode decides what to\n do with that information.\n\n Why we filter ``\"none\"`` / ``\"null\"``: Langflow's ``PlaceholderGraph``\n stringifies a missing user as ``\"None\"`` rather than the Python\n ``None`` value, so a naive truthiness check would mistake \"no user\" for\n a real user named \"None\" and create a spurious shared namespace.\n\n L2 binding atomicity: when a tool invocation is in progress, the\n pinned ContextVar value takes precedence so every read inside the\n same call sees the user_id captured at the binding check — even if\n ``self._user_id`` is mutated mid-call by a reused component instance.\n \"\"\"\n pinned = _pinned_user_id_var.get()\n if pinned is not None:\n return pinned\n candidates: list[object | None] = [getattr(self, \"_user_id\", None)]\n graph = getattr(self, \"graph\", None)\n if graph is not None:\n candidates.append(getattr(graph, \"user_id\", None))\n\n for value in candidates:\n if value is None:\n continue\n cleaned = str(value).strip()\n if not cleaned or cleaned.lower() in {\"none\", \"null\"}:\n continue\n return cleaned\n return None\n\n def _isolation_config(self) -> IsolationConfig:\n \"\"\"Read the on-disk layout config from the environment on every call.\n\n Re-read intentionally: tests and live operators tweak env vars between\n runs; caching would create stale-config bugs that are painful to\n diagnose. The cost is a handful of dict lookups per tool call.\n \"\"\"\n return load_isolation_config(env=os.environ, default_config_dir=_default_config_dir())\n\n def _resolve_auto_login(self) -> bool:\n \"\"\"Return AUTO_LOGIN from the live settings service.\n\n Defaults to True when the settings service is unavailable (tests, very\n early bootstrap) — mirrors the platform-wide default and keeps the\n component usable in lightweight contexts. Tests can override this\n method per-instance to pin the desired mode.\n \"\"\"\n try:\n settings_service = get_settings_service()\n except Exception: # noqa: BLE001 — service registry may not be ready\n return True\n if settings_service is None:\n return True\n try:\n return bool(settings_service.auth_settings.AUTO_LOGIN)\n except AttributeError:\n return True\n\n def _validate_root(self) -> Path:\n \"\"\"Resolve and authorize the effective sandbox root.\n\n Dispatch:\n - ``_force_isolation=True`` → /users//\n - AUTO_LOGIN=True → /shared/\n - AUTO_LOGIN=False + user_id → /users//\n - any mode with no user_id → PermissionError (caught by callers)\n\n ``_force_isolation`` exists for callers that carry an authenticated\n user identity and need per-user isolation regardless of the global\n AUTO_LOGIN flag (e.g. the agentic file router + the agent's write\n tools). It defaults to False so other call sites keep their current\n AUTO_LOGIN-driven behavior unchanged.\n \"\"\"\n config = self._isolation_config()\n\n if getattr(self, \"_force_isolation\", False):\n user_id = self._resolve_user_id()\n if not user_id:\n msg = \"FileSystemTool requires an authenticated user when _force_isolation is set\"\n raise PermissionError(msg)\n return self._isolated_user_root(config=config, user_id=user_id)\n\n if self._resolve_auto_login():\n return self._shared_root(config=config)\n\n user_id = self._resolve_user_id()\n if not user_id:\n msg = \"FileSystemTool requires an authenticated user when AUTO_LOGIN=False\"\n raise PermissionError(msg)\n return self._isolated_user_root(config=config, user_id=user_id)\n\n def _shared_root(self, *, config: IsolationConfig) -> Path:\n \"\"\"Materialize ``/shared/`` and verify the boundary.\n\n Why a fixed ``shared/`` prefix (and not just ``base_dir`` directly):\n keeps the on-disk layout symmetric with isolated mode (``users//``)\n so flipping AUTO_LOGIN at deploy time never mixes the two trees.\n \"\"\"\n shared_root = (config.base_dir / SHARED_NAMESPACE).resolve()\n try:\n shared_root.mkdir(parents=True, exist_ok=True)\n except OSError as exc:\n msg = (\n f\"Cannot create shared workspace at {shared_root}: \"\n f\"{exc.strerror or exc}. \"\n f\"Check that LANGFLOW_FS_TOOL_BASE_DIR ({config.base_dir}) is writable \"\n f\"by the Langflow process user.\"\n )\n raise PermissionError(msg) from exc\n\n sub_raw = (self.root_path or \"\").strip()\n sub = sub_raw.lstrip(\"/\\\\\")\n candidate = (shared_root / sub).resolve() if sub else shared_root\n if not candidate.is_relative_to(shared_root):\n msg = f\"sub_path {self.root_path!r} escapes shared workspace\"\n raise PermissionError(msg)\n try:\n candidate.mkdir(parents=True, exist_ok=True)\n except OSError as exc:\n msg = f\"Cannot create sub-path {candidate}: {exc.strerror or exc}\"\n raise PermissionError(msg) from exc\n return candidate\n\n def _isolated_user_root(self, *, config: IsolationConfig, user_id: str) -> Path:\n \"\"\"Materialize ``/users//`` and verify the boundary.\"\"\"\n try:\n pepper = load_or_create_pepper(config.pepper_path)\n except OSError as exc:\n msg = (\n f\"Cannot access pepper file at {config.pepper_path}: \"\n f\"{exc.strerror or exc}. \"\n f\"Check that LANGFLOW_FS_TOOL_PEPPER_PATH points to a writable location.\"\n )\n raise PermissionError(msg) from exc\n namespace = compute_user_namespace(user_id, pepper=pepper)\n user_root = (config.base_dir / namespace).resolve()\n try:\n user_root.mkdir(parents=True, exist_ok=True)\n except OSError as exc:\n msg = (\n f\"Cannot create user namespace at {user_root}: \"\n f\"{exc.strerror or exc}. \"\n f\"Check that LANGFLOW_FS_TOOL_BASE_DIR ({config.base_dir}) is writable \"\n f\"by the Langflow process user.\"\n )\n raise PermissionError(msg) from exc\n\n sub_raw = (self.root_path or \"\").strip()\n # Strip leading separators so absolute-looking sub_paths are pinned\n # under the user root rather than escaping to the host filesystem.\n sub = sub_raw.lstrip(\"/\\\\\")\n candidate = (user_root / sub).resolve() if sub else user_root\n if not candidate.is_relative_to(user_root):\n msg = f\"sub_path {self.root_path!r} escapes user namespace\"\n raise PermissionError(msg)\n try:\n candidate.mkdir(parents=True, exist_ok=True)\n except OSError as exc:\n msg = f\"Cannot create sub-path {candidate}: {exc.strerror or exc}\"\n raise PermissionError(msg) from exc\n return candidate\n\n def _validate_path(self, path: str) -> Path:\n \"\"\"Resolve `path` relative to the sandbox root and reject any escape.\n\n Why raise: internal helper for control flow; each tool operation catches\n PermissionError and translates it into a structured error for the agent.\n \"\"\"\n if \"\\x00\" in path:\n msg = \"Path contains NUL byte\"\n raise PermissionError(msg)\n if portability_error := _check_windows_portability(path):\n raise PermissionError(portability_error)\n # Block the reserved segments (`.lfsig` integrity hook, `.components`\n # registered-user-component store). We forbid traversal even by users\n # with valid credentials — agents and humans both — because the\n # privilege model depends on these directories being unreachable from\n # the public tool surface.\n # Compare case-insensitively: APFS and NTFS resolve `.LFSIG` to the\n # same directory as `.lfsig`, so a case-sensitive equality check\n # lets uppercase variants slip past the reservation on those\n # filesystems. `casefold` is the locale-aware lowercase used for\n # caseless string comparison — broader than `.lower()`.\n reserved_folds = {seg.casefold() for seg in RESERVED_SEGMENTS}\n for part in PureWindowsPath(path).parts:\n folded = part.casefold()\n if folded in reserved_folds:\n # Surface the canonical segment name (with leading dot) so\n # error messages stay stable across modes and OSes.\n canonical = next(seg for seg in RESERVED_SEGMENTS if seg.casefold() == folded)\n msg = f\"Path component {canonical!r} is reserved\"\n raise PermissionError(msg)\n if deny_error := _check_deny_list(path):\n raise PermissionError(deny_error)\n root_resolved = self._validate_root()\n candidate = (root_resolved / path).resolve()\n if not candidate.is_relative_to(root_resolved):\n msg = f\"Path escapes workspace boundary: {path}\"\n raise PermissionError(msg)\n # Re-check the resolved name: a symlink with an innocuous basename can\n # alias a denied target that `.resolve()` has already followed.\n if deny_error := _check_deny_list(str(candidate.relative_to(root_resolved))):\n raise PermissionError(deny_error)\n if hardlink_error := _check_hardlink(candidate):\n raise PermissionError(hardlink_error)\n return candidate\n\n def _read_file(self, path: str, offset: int | None = None, limit: int | None = None) -> dict:\n try:\n resolved = self._validate_path(path)\n except PermissionError as exc:\n return {\"error\": str(exc), \"path\": path}\n\n if not resolved.exists():\n return {\"error\": f\"File not found: {path}\", \"path\": path}\n if resolved.is_dir():\n return {\"error\": f\"Path is a directory, not a file: {path}\", \"path\": path}\n\n try:\n size = resolved.stat().st_size\n except OSError as exc:\n return {\"error\": f\"Cannot stat file: {exc.strerror or exc}\", \"path\": path}\n if size > MAX_FILE_SIZE_BYTES:\n return {\n \"error\": f\"File size {size} exceeds limit of {MAX_FILE_SIZE_BYTES} bytes\",\n \"path\": path,\n }\n\n try:\n head = _read_head_no_follow(resolved, BINARY_SNIFF_BYTES)\n except OSError as exc:\n return {\"error\": f\"Cannot read file: {exc.strerror or exc}\", \"path\": path}\n if _looks_binary(head):\n return {\"error\": f\"Refusing to read binary file: {path}\", \"path\": path}\n\n try:\n text = _read_bytes_no_follow(resolved).decode(\"utf-8\", errors=\"replace\")\n except OSError as exc:\n return {\"error\": f\"Cannot read file: {exc.strerror or exc}\", \"path\": path}\n\n lines = text.splitlines()\n total = len(lines)\n start = offset if offset and offset > 0 else 1\n end = total if limit is None else min(total, start - 1 + limit)\n window = lines[start - 1 : end]\n numbered = \"\\n\".join(f\"{i:>6}→{line}\" for i, line in enumerate(window, start=start))\n return {\n \"status\": \"ok\",\n \"path\": path,\n \"content\": numbered,\n \"total_lines\": total,\n \"start_line\": start,\n \"num_lines\": len(window),\n }\n\n def _write_file(self, path: str, content: str) -> dict:\n try:\n resolved = self._validate_path(path)\n except PermissionError as exc:\n return {\"error\": str(exc), \"path\": path}\n\n encoded = content.encode(\"utf-8\")\n if len(encoded) > MAX_FILE_SIZE_BYTES:\n return {\n \"error\": f\"Content size {len(encoded)} exceeds limit of {MAX_FILE_SIZE_BYTES} bytes\",\n \"path\": path,\n }\n\n if resolved.is_dir():\n return {\"error\": f\"Path is a directory, not a file: {path}\", \"path\": path}\n # Auto-create missing parent directories. `_validate_path` has already\n # confirmed `resolved` lies inside the sandbox root, so every ancestor\n # of `resolved.parent` is also inside the root — `mkdir(parents=True)`\n # cannot escape the sandbox.\n try:\n resolved.parent.mkdir(parents=True, exist_ok=True)\n except OSError as exc:\n return {\"error\": f\"Cannot create parent directory: {exc.strerror or exc}\", \"path\": path}\n\n existed = resolved.exists()\n try:\n _write_bytes_no_follow(resolved, encoded)\n except OSError as exc:\n return {\"error\": f\"Cannot write file: {exc.strerror or exc}\", \"path\": path}\n\n return {\n \"status\": \"updated\" if existed else \"created\",\n \"path\": path,\n \"bytes_written\": len(encoded),\n }\n\n def _edit_file(\n self,\n path: str,\n old_string: str,\n new_string: str,\n *,\n replace_all: bool = False,\n ) -> dict:\n try:\n resolved = self._validate_path(path)\n except PermissionError as exc:\n return {\"error\": str(exc), \"path\": path}\n\n if not resolved.exists():\n return {\"error\": f\"File not found: {path}\", \"path\": path}\n if resolved.is_dir():\n return {\"error\": f\"Path is a directory, not a file: {path}\", \"path\": path}\n\n # Reject empty old_string outright. With `old_string=\"\"`, str.replace\n # inserts new_string between every character (and at both ends),\n # producing roughly N*len(new_string) extra bytes — a small file plus\n # a large new_string can blow far past MAX_FILE_SIZE_BYTES before the\n # post-construction guard runs. Empty old_string also has no\n # well-defined semantics for \"edit this exact match\".\n if old_string == \"\":\n return {\"error\": \"old_string must not be empty\", \"path\": path}\n\n try:\n current = _read_bytes_no_follow(resolved).decode(\"utf-8\", errors=\"replace\")\n except OSError as exc:\n return {\"error\": f\"Cannot read file: {exc.strerror or exc}\", \"path\": path}\n\n occurrences = current.count(old_string)\n if occurrences == 0:\n return {\"error\": f\"old_string not found in file: {path}\", \"path\": path}\n if occurrences > 1 and not replace_all:\n return {\n \"error\": (\n f\"old_string is ambiguous: {occurrences} multiple matches in {path}. \"\n \"Pass replace_all=True to replace every occurrence.\"\n ),\n \"path\": path,\n \"matches\": occurrences,\n }\n\n # Project the encoded size BEFORE building the replacement string.\n # str.replace allocates the full result up front, so a small file\n # with replace_all=True and a large new_string can balloon memory\n # before the post-allocation length check runs. UTF-8 byte counts are\n # exact (no estimation) so the projection is precise.\n old_bytes = len(old_string.encode(\"utf-8\"))\n new_bytes = len(new_string.encode(\"utf-8\"))\n current_bytes = len(current.encode(\"utf-8\"))\n replacements_planned = occurrences if replace_all else 1\n projected_bytes = current_bytes + replacements_planned * (new_bytes - old_bytes)\n if projected_bytes > MAX_FILE_SIZE_BYTES:\n return {\n \"error\": (\n f\"Resulting content size {projected_bytes} would exceed limit of {MAX_FILE_SIZE_BYTES} bytes\"\n ),\n \"path\": path,\n }\n\n updated = current.replace(old_string, new_string) if replace_all else current.replace(old_string, new_string, 1)\n encoded = updated.encode(\"utf-8\")\n # Defensive re-check (should be unreachable given the projection above,\n # but keeps the original invariant explicit).\n if len(encoded) > MAX_FILE_SIZE_BYTES:\n return {\n \"error\": f\"Resulting content size {len(encoded)} exceeds limit of {MAX_FILE_SIZE_BYTES} bytes\",\n \"path\": path,\n }\n\n try:\n _write_bytes_no_follow(resolved, encoded)\n except OSError as exc:\n return {\"error\": f\"Cannot write file: {exc.strerror or exc}\", \"path\": path}\n\n return {\n \"status\": \"ok\",\n \"path\": path,\n \"replacements\": occurrences if replace_all else 1,\n \"old_string\": old_string,\n \"new_string\": new_string,\n }\n\n def _glob_search(self, pattern: str, path: str | None = None) -> dict:\n # Empty patterns make ``Path.glob`` raise ``ValueError`` instead of\n # returning an empty match list, which would propagate uncaught and\n # crash the agent. Surface a structured error instead.\n if not pattern or not pattern.strip():\n return {\"error\": \"Pattern must not be empty\", \"pattern\": pattern, \"path\": path}\n # Reject traversal segments in the pattern itself. Without this guard,\n # ``base.glob(\"../*\")`` walks one directory up; one of the resulting\n # paths (``/../``) resolves back to ``base`` and\n # is surfaced to the agent as ``\".\"`` — a silent, misleading hit.\n # ``PureWindowsPath`` splits on both ``/`` and ``\\`` so the check\n # covers Windows-authored patterns as well.\n if any(part == \"..\" for part in PureWindowsPath(pattern).parts):\n return {\n \"error\": \"Pattern must not contain '..' (path traversal not allowed)\",\n \"pattern\": pattern,\n \"path\": path,\n }\n try:\n root_resolved = self._validate_root()\n base = self._validate_path(path) if path else root_resolved\n except PermissionError as exc:\n return {\"error\": str(exc), \"pattern\": pattern, \"path\": path}\n\n if not base.exists() or not base.is_dir():\n return {\"error\": f\"Base path is not a directory: {path or '.'}\", \"pattern\": pattern}\n\n # Collect up to GLOB_SCAN_CEILING (>> GLOB_RESULT_LIMIT) so we can\n # surface diverse branches even when one directory dominates the\n # iteration order. See BUG-02 / T2-001.\n collected: list[str] = []\n for match in base.glob(pattern):\n try:\n resolved_match = match.resolve()\n except OSError:\n continue\n if not resolved_match.is_relative_to(root_resolved):\n continue\n relative_match = str(resolved_match.relative_to(root_resolved))\n if _check_deny_list(relative_match):\n continue\n collected.append(relative_match)\n if len(collected) >= GLOB_SCAN_CEILING:\n break\n\n # Sort for cross-filesystem determinism, then truncate to the visible\n # cap. Emit `truncated_branches` so the agent has a non-silent signal:\n # top-level branches under `base` whose files appear in the omitted\n # tail. The agent can narrow with `path=` to recover them.\n collected.sort()\n truncated = len(collected) > GLOB_RESULT_LIMIT\n if truncated:\n omitted = collected[GLOB_RESULT_LIMIT:]\n matches = collected[:GLOB_RESULT_LIMIT]\n truncated_branches = sorted({Path(p).parts[0] for p in omitted if Path(p).parts})\n else:\n matches = collected\n truncated_branches = []\n\n return {\n \"status\": \"ok\",\n \"pattern\": pattern,\n \"matches\": matches,\n \"truncated\": truncated,\n \"truncated_branches\": truncated_branches,\n }\n\n def _grep_search(\n self,\n pattern: str,\n path: str | None = None,\n glob: str | None = None,\n *,\n case_insensitive: bool = False,\n output_mode: str = \"files_with_matches\",\n is_regex: bool = False,\n ) -> dict:\n if output_mode not in GREP_OUTPUT_MODES:\n return {\n \"error\": f\"Invalid output_mode '{output_mode}'. Must be one of {GREP_OUTPUT_MODES}\",\n \"pattern\": pattern,\n }\n\n # Build a per-line matcher. Default is a literal substring check —\n # safe for any user-supplied pattern. Regex is opt-in and is gated by\n # a static heuristic plus per-line/per-file caps. Stdlib `re` cannot\n # be cancelled (it holds the GIL), so prevention is the only viable\n # mitigation: reject pathological patterns at compile time and bound\n # the input we hand to the engine.\n if is_regex:\n if len(pattern) > GREP_PATTERN_MAX_LEN:\n return {\n \"error\": (\n f\"Regex pattern exceeds {GREP_PATTERN_MAX_LEN} chars; \"\n \"shorten it or use literal mode (is_regex=False).\"\n ),\n \"pattern\": pattern,\n }\n if _looks_like_redos(pattern):\n return {\n \"error\": (\n \"Regex pattern rejected: nested unbounded quantifier \"\n \"(catastrophic-backtracking risk). Rewrite without an \"\n \"outer +/* on a group that already contains +, *, {n,}, \"\n \"or |, or use literal mode (is_regex=False).\"\n ),\n \"pattern\": pattern,\n }\n try:\n flags = re.IGNORECASE if case_insensitive else 0\n regex = re.compile(pattern, flags)\n except re.error as exc:\n return {\"error\": f\"Invalid regex pattern: {exc}\", \"pattern\": pattern}\n\n def line_matches(line: str) -> bool:\n # Skip regex matching on oversized lines — they are usually\n # data blobs, and a long input is a prerequisite for most\n # practical exponential-backtracking attacks.\n if len(line) > GREP_REGEX_LINE_MAX_LEN:\n return False\n return regex.search(line) is not None\n else:\n needle = pattern.lower() if case_insensitive else pattern\n\n def line_matches(line: str) -> bool:\n hay = line.lower() if case_insensitive else line\n return needle in hay\n\n try:\n root_resolved = self._validate_root()\n base = self._validate_path(path) if path else root_resolved\n except PermissionError as exc:\n return {\"error\": str(exc), \"pattern\": pattern, \"path\": path}\n\n if not base.exists():\n return {\"error\": f\"Path does not exist: {path or '.'}\", \"pattern\": pattern}\n\n targets = self._collect_grep_targets(base, glob)\n files_with_matches: list[str] = []\n content_matches: list[dict] = []\n count_matches: list[dict] = []\n line_budget = GREP_LINE_LIMIT\n truncated = False\n\n for file in targets:\n try:\n resolved_file = file.resolve()\n except OSError:\n continue\n if not resolved_file.is_relative_to(root_resolved):\n continue\n rel_path = str(resolved_file.relative_to(root_resolved))\n if _check_deny_list(rel_path):\n continue\n try:\n size = resolved_file.stat().st_size\n except OSError:\n continue\n if size > MAX_FILE_SIZE_BYTES:\n continue\n try:\n with resolved_file.open(\"rb\") as fh:\n head = fh.read(BINARY_SNIFF_BYTES)\n except OSError:\n continue\n if _looks_binary(head):\n continue\n try:\n text = resolved_file.read_text(encoding=\"utf-8\", errors=\"replace\")\n except OSError:\n continue\n\n per_file_count = 0\n matched_any = False\n for idx, line in enumerate(text.splitlines(), start=1):\n # Per-file scan cap (regex only — literal scans are O(n)).\n if is_regex and idx > GREP_REGEX_LINES_PER_FILE:\n break\n if not line_matches(line):\n continue\n matched_any = True\n per_file_count += 1\n if output_mode == \"content\":\n if line_budget <= 0:\n truncated = True\n break\n content_matches.append({\"path\": rel_path, \"line_number\": idx, \"line\": line})\n line_budget -= 1\n\n if matched_any:\n files_with_matches.append(rel_path)\n if output_mode == \"count\":\n count_matches.append({\"path\": rel_path, \"count\": per_file_count})\n\n if output_mode == \"content\" and truncated:\n break\n\n if output_mode == \"files_with_matches\":\n matches: list = files_with_matches\n elif output_mode == \"content\":\n matches = content_matches\n else: # count\n matches = count_matches\n\n return {\n \"status\": \"ok\",\n \"pattern\": pattern,\n \"output_mode\": output_mode,\n \"matches\": matches,\n \"truncated\": truncated,\n }\n\n def _collect_grep_targets(self, base: Path, glob: str | None) -> list[Path]:\n if base.is_file():\n return [base]\n all_files = [p for p in base.rglob(\"*\") if p.is_file()]\n if glob:\n return [p for p in all_files if p.match(glob)]\n return all_files\n"
},
"read_only": {
"_input_type": "BoolInput",
@@ -74175,13 +74175,9 @@
"icon": "Glean",
"legacy": false,
"metadata": {
- "code_hash": "469618609b03",
+ "code_hash": "e23ec66720a9",
"dependencies": {
"dependencies": [
- {
- "name": "httpx",
- "version": "0.28.1"
- },
{
"name": "langchain_core",
"version": "1.4.0"
@@ -74195,7 +74191,7 @@
"version": null
}
],
- "total_dependencies": 4
+ "total_dependencies": 3
},
"module": "lfx.components.glean.glean_search_api.GleanSearchAPIComponent"
},
@@ -74236,7 +74232,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import json\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_core.tools import StructuredTool, ToolException\nfrom pydantic import BaseModel\nfrom pydantic.v1 import Field\n\nfrom lfx.base.langchain_utilities.model import LCToolComponent\nfrom lfx.field_typing import Tool\nfrom lfx.inputs.inputs import IntInput, MultilineInput, NestedDictInput, SecretStrInput, StrInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\n\n\nclass GleanSearchAPISchema(BaseModel):\n query: str = Field(..., description=\"The search query\")\n page_size: int = Field(10, description=\"Maximum number of results to return\")\n request_options: dict[str, Any] | None = Field(default_factory=dict, description=\"Request Options\")\n\n\nclass GleanAPIWrapper(BaseModel):\n \"\"\"Wrapper around Glean API.\"\"\"\n\n glean_api_url: str\n glean_access_token: str\n act_as: str = \"langflow-component@datastax.com\" # TODO: Detect this\n\n def _prepare_request(\n self,\n query: str,\n page_size: int = 10,\n request_options: dict[str, Any] | None = None,\n ) -> dict:\n # Ensure there's a trailing slash\n url = self.glean_api_url\n if not url.endswith(\"/\"):\n url += \"/\"\n\n return {\n \"url\": urljoin(url, \"search\"),\n \"headers\": {\n \"Authorization\": f\"Bearer {self.glean_access_token}\",\n \"X-Scio-ActAs\": self.act_as,\n },\n \"payload\": {\n \"query\": query,\n \"pageSize\": page_size,\n \"requestOptions\": request_options,\n },\n }\n\n def results(self, query: str, **kwargs: Any) -> list[dict[str, Any]]:\n results = self._search_api_results(query, **kwargs)\n\n if len(results) == 0:\n msg = \"No good Glean Search Result was found\"\n raise AssertionError(msg)\n\n return results\n\n def run(self, query: str, **kwargs: Any) -> list[dict[str, Any]]:\n try:\n results = self.results(query, **kwargs)\n\n processed_results = []\n for result in results:\n if \"title\" in result:\n result[\"snippets\"] = result.get(\"snippets\", [{\"snippet\": {\"text\": result[\"title\"]}}])\n if \"text\" not in result[\"snippets\"][0]:\n result[\"snippets\"][0][\"text\"] = result[\"title\"]\n\n processed_results.append(result)\n except Exception as e:\n error_message = f\"Error in Glean Search API: {e!s}\"\n raise ToolException(error_message) from e\n\n return processed_results\n\n def _search_api_results(self, query: str, **kwargs: Any) -> list[dict[str, Any]]:\n request_details = self._prepare_request(query, **kwargs)\n\n response = httpx.post(\n request_details[\"url\"],\n json=request_details[\"payload\"],\n headers=request_details[\"headers\"],\n )\n\n response.raise_for_status()\n response_json = response.json()\n\n return response_json.get(\"results\", [])\n\n @staticmethod\n def _result_as_string(result: dict) -> str:\n return json.dumps(result, indent=4)\n\n\nclass GleanSearchAPIComponent(LCToolComponent):\n display_name: str = \"Glean Search API\"\n description: str = \"Search using Glean's API.\"\n documentation: str = \"https://docs.langflow.org/bundles-glean\"\n icon: str = \"Glean\"\n\n outputs = [\n Output(display_name=\"Table\", name=\"dataframe\", method=\"fetch_content_dataframe\"),\n ]\n\n inputs = [\n StrInput(name=\"glean_api_url\", display_name=\"Glean API URL\", required=True),\n SecretStrInput(name=\"glean_access_token\", display_name=\"Glean Access Token\", required=True),\n MultilineInput(name=\"query\", display_name=\"Query\", required=True, tool_mode=True),\n IntInput(name=\"page_size\", display_name=\"Page Size\", value=10),\n NestedDictInput(name=\"request_options\", display_name=\"Request Options\", required=False),\n ]\n\n def build_tool(self) -> Tool:\n wrapper = self._build_wrapper(\n glean_api_url=self.glean_api_url,\n glean_access_token=self.glean_access_token,\n )\n\n tool = StructuredTool.from_function(\n name=\"glean_search_api\",\n description=\"Search Glean for relevant results.\",\n func=wrapper.run,\n args_schema=GleanSearchAPISchema,\n )\n\n self.status = \"Glean Search API Tool for Langchain\"\n\n return tool\n\n def run_model(self) -> DataFrame:\n return self.fetch_content_dataframe()\n\n def fetch_content(self) -> list[Data]:\n tool = self.build_tool()\n\n results = tool.run(\n {\n \"query\": self.query,\n \"page_size\": self.page_size,\n \"request_options\": self.request_options,\n }\n )\n\n # Build the data\n data = [Data(data=result, text=result[\"snippets\"][0][\"text\"]) for result in results]\n self.status = data # type: ignore[assignment]\n\n return data\n\n def _build_wrapper(\n self,\n glean_api_url: str,\n glean_access_token: str,\n ):\n return GleanAPIWrapper(\n glean_api_url=glean_api_url,\n glean_access_token=glean_access_token,\n )\n\n def fetch_content_dataframe(self) -> DataFrame:\n \"\"\"Convert the Glean search results to a DataFrame.\n\n Returns:\n DataFrame: A DataFrame containing the search results.\n \"\"\"\n data = self.fetch_content()\n return DataFrame(data)\n"
+ "value": "import json\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nfrom langchain_core.tools import StructuredTool, ToolException\nfrom pydantic import BaseModel\nfrom pydantic.v1 import Field\n\nfrom lfx.base.langchain_utilities.model import LCToolComponent\nfrom lfx.field_typing import Tool\nfrom lfx.inputs.inputs import IntInput, MultilineInput, NestedDictInput, SecretStrInput, StrInput\nfrom lfx.io import Output\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.utils.ssrf_httpx import ssrf_safe_httpx_post\n\n\nclass GleanSearchAPISchema(BaseModel):\n query: str = Field(..., description=\"The search query\")\n page_size: int = Field(10, description=\"Maximum number of results to return\")\n request_options: dict[str, Any] | None = Field(default_factory=dict, description=\"Request Options\")\n\n\nclass GleanAPIWrapper(BaseModel):\n \"\"\"Wrapper around Glean API.\"\"\"\n\n glean_api_url: str\n glean_access_token: str\n act_as: str = \"langflow-component@datastax.com\" # TODO: Detect this\n\n def _prepare_request(\n self,\n query: str,\n page_size: int = 10,\n request_options: dict[str, Any] | None = None,\n ) -> dict:\n # Ensure there's a trailing slash\n url = self.glean_api_url\n if not url.endswith(\"/\"):\n url += \"/\"\n\n return {\n \"url\": urljoin(url, \"search\"),\n \"headers\": {\n \"Authorization\": f\"Bearer {self.glean_access_token}\",\n \"X-Scio-ActAs\": self.act_as,\n },\n \"payload\": {\n \"query\": query,\n \"pageSize\": page_size,\n \"requestOptions\": request_options,\n },\n }\n\n def results(self, query: str, **kwargs: Any) -> list[dict[str, Any]]:\n results = self._search_api_results(query, **kwargs)\n\n if len(results) == 0:\n msg = \"No good Glean Search Result was found\"\n raise AssertionError(msg)\n\n return results\n\n def run(self, query: str, **kwargs: Any) -> list[dict[str, Any]]:\n try:\n results = self.results(query, **kwargs)\n\n processed_results = []\n for result in results:\n if \"title\" in result:\n result[\"snippets\"] = result.get(\"snippets\", [{\"snippet\": {\"text\": result[\"title\"]}}])\n if \"text\" not in result[\"snippets\"][0]:\n result[\"snippets\"][0][\"text\"] = result[\"title\"]\n\n processed_results.append(result)\n except Exception as e:\n error_message = f\"Error in Glean Search API: {e!s}\"\n raise ToolException(error_message) from e\n\n return processed_results\n\n def _search_api_results(self, query: str, **kwargs: Any) -> list[dict[str, Any]]:\n request_details = self._prepare_request(query, **kwargs)\n\n response = ssrf_safe_httpx_post(\n request_details[\"url\"],\n json=request_details[\"payload\"],\n headers=request_details[\"headers\"],\n )\n\n response.raise_for_status()\n response_json = response.json()\n\n return response_json.get(\"results\", [])\n\n @staticmethod\n def _result_as_string(result: dict) -> str:\n return json.dumps(result, indent=4)\n\n\nclass GleanSearchAPIComponent(LCToolComponent):\n display_name: str = \"Glean Search API\"\n description: str = \"Search using Glean's API.\"\n documentation: str = \"https://docs.langflow.org/bundles-glean\"\n icon: str = \"Glean\"\n\n outputs = [\n Output(display_name=\"Table\", name=\"dataframe\", method=\"fetch_content_dataframe\"),\n ]\n\n inputs = [\n StrInput(name=\"glean_api_url\", display_name=\"Glean API URL\", required=True),\n SecretStrInput(name=\"glean_access_token\", display_name=\"Glean Access Token\", required=True),\n MultilineInput(name=\"query\", display_name=\"Query\", required=True, tool_mode=True),\n IntInput(name=\"page_size\", display_name=\"Page Size\", value=10),\n NestedDictInput(name=\"request_options\", display_name=\"Request Options\", required=False),\n ]\n\n def build_tool(self) -> Tool:\n wrapper = self._build_wrapper(\n glean_api_url=self.glean_api_url,\n glean_access_token=self.glean_access_token,\n )\n\n tool = StructuredTool.from_function(\n name=\"glean_search_api\",\n description=\"Search Glean for relevant results.\",\n func=wrapper.run,\n args_schema=GleanSearchAPISchema,\n )\n\n self.status = \"Glean Search API Tool for Langchain\"\n\n return tool\n\n def run_model(self) -> DataFrame:\n return self.fetch_content_dataframe()\n\n def fetch_content(self) -> list[Data]:\n tool = self.build_tool()\n\n results = tool.run(\n {\n \"query\": self.query,\n \"page_size\": self.page_size,\n \"request_options\": self.request_options,\n }\n )\n\n # Build the data\n data = [Data(data=result, text=result[\"snippets\"][0][\"text\"]) for result in results]\n self.status = data # type: ignore[assignment]\n\n return data\n\n def _build_wrapper(\n self,\n glean_api_url: str,\n glean_access_token: str,\n ):\n return GleanAPIWrapper(\n glean_api_url=glean_api_url,\n glean_access_token=glean_access_token,\n )\n\n def fetch_content_dataframe(self) -> DataFrame:\n \"\"\"Convert the Glean search results to a DataFrame.\n\n Returns:\n DataFrame: A DataFrame containing the search results.\n \"\"\"\n data = self.fetch_content()\n return DataFrame(data)\n"
},
"glean_access_token": {
"_input_type": "SecretStrInput",
@@ -76402,12 +76398,12 @@
"icon": "HomeAssistant",
"legacy": false,
"metadata": {
- "code_hash": "9cdd3b449d6c",
+ "code_hash": "62875eabc68b",
"dependencies": {
"dependencies": [
{
- "name": "requests",
- "version": "2.34.2"
+ "name": "httpx",
+ "version": "0.28.1"
},
{
"name": "langchain_core",
@@ -76498,7 +76494,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import json\nfrom typing import Any\n\nimport requests\nfrom langchain_core.tools import StructuredTool\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 SecretStrInput, StrInput\nfrom lfx.schema.data import Data\n\n\nclass HomeAssistantControl(LCToolComponent):\n \"\"\"This tool is used to control Home Assistant devices.\n\n A very simple tool to control Home Assistant devices.\n - The agent only needs to provide action (turn_on, turn_off, toggle) + entity_id (e.g., switch.xxx, light.xxx).\n - The domain (e.g., 'switch', 'light') is automatically extracted from entity_id.\n \"\"\"\n\n display_name: str = \"Home Assistant Control\"\n description: str = (\n \"A very simple tool to control Home Assistant devices. \"\n \"Only action (turn_on, turn_off, toggle) and entity_id need to be provided.\"\n )\n documentation: str = \"https://developers.home-assistant.io/docs/api/rest/\"\n icon: str = \"HomeAssistant\"\n\n # --- Input fields for LangFlow UI (token, URL) ---\n inputs = [\n SecretStrInput(\n name=\"ha_token\",\n display_name=\"Home Assistant Token\",\n info=\"Home Assistant Long-Lived Access Token\",\n required=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Home Assistant URL\",\n info=\"e.g., http://192.168.0.10:8123\",\n required=True,\n ),\n StrInput(\n name=\"default_action\",\n display_name=\"Default Action (Optional)\",\n info=\"One of turn_on, turn_off, toggle\",\n required=False,\n ),\n StrInput(\n name=\"default_entity_id\",\n display_name=\"Default Entity ID (Optional)\",\n info=\"Default entity ID to control (e.g., switch.unknown_switch_3)\",\n required=False,\n ),\n ]\n\n # --- Parameters exposed to the agent (Pydantic schema) ---\n class ToolSchema(BaseModel):\n \"\"\"Parameters to be passed by the agent: action, entity_id only.\"\"\"\n\n action: str = Field(..., description=\"Home Assistant service name. (One of turn_on, turn_off, toggle)\")\n entity_id: str = Field(\n ...,\n description=\"Entity ID to control (e.g., switch.xxx, light.xxx, cover.xxx, etc.).\"\n \"Do not infer; use the list_homeassistant_states tool to retrieve it.\",\n )\n\n def run_model(self) -> Data:\n \"\"\"Used when the 'Run' button is clicked in LangFlow.\n\n - Uses default_action and default_entity_id entered in the UI.\n \"\"\"\n action = self.default_action or \"turn_off\"\n entity_id = self.default_entity_id or \"switch.unknown_switch_3\"\n\n result = self._control_device(\n ha_token=self.ha_token,\n base_url=self.base_url,\n action=action,\n entity_id=entity_id,\n )\n return self._make_data_response(result)\n\n def build_tool(self) -> Tool:\n \"\"\"Returns a tool to be used by the agent (LLM).\n\n - The agent can only pass action and entity_id as arguments.\n \"\"\"\n return StructuredTool.from_function(\n name=\"home_assistant_control\",\n description=(\n \"A tool to control Home Assistant devices easily. \"\n \"Parameters: action ('turn_on'/'turn_off'/'toggle'), entity_id ('switch.xxx', etc.).\"\n \"Entity ID must be obtained using the list_homeassistant_states tool and not guessed.\"\n ),\n func=self._control_device_for_tool, # Wrapper function below\n args_schema=self.ToolSchema,\n )\n\n def _control_device_for_tool(self, action: str, entity_id: str) -> dict[str, Any] | str:\n \"\"\"Function called by the agent.\n\n -> Internally calls _control_device.\n \"\"\"\n return self._control_device(\n ha_token=self.ha_token,\n base_url=self.base_url,\n action=action,\n entity_id=entity_id,\n )\n\n def _control_device(\n self,\n ha_token: str,\n base_url: str,\n action: str,\n entity_id: str,\n ) -> dict[str, Any] | str:\n \"\"\"Actual logic to call the Home Assistant service.\n\n The domain is extracted from the beginning of the entity_id.\n Example: entity_id=\"switch.unknown_switch_3\" -> domain=\"switch\".\n \"\"\"\n try:\n domain = entity_id.split(\".\")[0] # switch, light, cover, etc.\n url = f\"{base_url}/api/services/{domain}/{action}\"\n\n headers = {\n \"Authorization\": f\"Bearer {ha_token}\",\n \"Content-Type\": \"application/json\",\n }\n payload = {\"entity_id\": entity_id}\n\n response = requests.post(url, headers=headers, json=payload, timeout=10)\n response.raise_for_status()\n\n return response.json() # HA response JSON on success\n except requests.exceptions.RequestException as e:\n return f\"Error: Failed to call service. {e}\"\n except Exception as e: # noqa: BLE001\n return f\"An unexpected error occurred: {e}\"\n\n def _make_data_response(self, result: dict[str, Any] | str) -> Data:\n \"\"\"Returns a response in the LangFlow Data format.\"\"\"\n if isinstance(result, str):\n # Handle error messages\n return Data(text=result)\n\n # Convert dict to JSON string\n formatted_json = json.dumps(result, indent=2, ensure_ascii=False)\n return Data(data=result, text=formatted_json)\n"
+ "value": "import json\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.tools import StructuredTool\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 SecretStrInput, StrInput\nfrom lfx.schema.data import Data\nfrom lfx.utils.ssrf_httpx import ssrf_safe_httpx_post\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\n\n\nclass HomeAssistantControl(LCToolComponent):\n \"\"\"This tool is used to control Home Assistant devices.\n\n A very simple tool to control Home Assistant devices.\n - The agent only needs to provide action (turn_on, turn_off, toggle) + entity_id (e.g., switch.xxx, light.xxx).\n - The domain (e.g., 'switch', 'light') is automatically extracted from entity_id.\n \"\"\"\n\n display_name: str = \"Home Assistant Control\"\n description: str = (\n \"A very simple tool to control Home Assistant devices. \"\n \"Only action (turn_on, turn_off, toggle) and entity_id need to be provided.\"\n )\n documentation: str = \"https://developers.home-assistant.io/docs/api/rest/\"\n icon: str = \"HomeAssistant\"\n\n # --- Input fields for LangFlow UI (token, URL) ---\n inputs = [\n SecretStrInput(\n name=\"ha_token\",\n display_name=\"Home Assistant Token\",\n info=\"Home Assistant Long-Lived Access Token\",\n required=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Home Assistant URL\",\n info=\"e.g., http://192.168.0.10:8123\",\n required=True,\n ),\n StrInput(\n name=\"default_action\",\n display_name=\"Default Action (Optional)\",\n info=\"One of turn_on, turn_off, toggle\",\n required=False,\n ),\n StrInput(\n name=\"default_entity_id\",\n display_name=\"Default Entity ID (Optional)\",\n info=\"Default entity ID to control (e.g., switch.unknown_switch_3)\",\n required=False,\n ),\n ]\n\n # --- Parameters exposed to the agent (Pydantic schema) ---\n class ToolSchema(BaseModel):\n \"\"\"Parameters to be passed by the agent: action, entity_id only.\"\"\"\n\n action: str = Field(..., description=\"Home Assistant service name. (One of turn_on, turn_off, toggle)\")\n entity_id: str = Field(\n ...,\n description=\"Entity ID to control (e.g., switch.xxx, light.xxx, cover.xxx, etc.).\"\n \"Do not infer; use the list_homeassistant_states tool to retrieve it.\",\n )\n\n def run_model(self) -> Data:\n \"\"\"Used when the 'Run' button is clicked in LangFlow.\n\n - Uses default_action and default_entity_id entered in the UI.\n \"\"\"\n action = self.default_action or \"turn_off\"\n entity_id = self.default_entity_id or \"switch.unknown_switch_3\"\n\n result = self._control_device(\n ha_token=self.ha_token,\n base_url=self.base_url,\n action=action,\n entity_id=entity_id,\n )\n return self._make_data_response(result)\n\n def build_tool(self) -> Tool:\n \"\"\"Returns a tool to be used by the agent (LLM).\n\n - The agent can only pass action and entity_id as arguments.\n \"\"\"\n return StructuredTool.from_function(\n name=\"home_assistant_control\",\n description=(\n \"A tool to control Home Assistant devices easily. \"\n \"Parameters: action ('turn_on'/'turn_off'/'toggle'), entity_id ('switch.xxx', etc.).\"\n \"Entity ID must be obtained using the list_homeassistant_states tool and not guessed.\"\n ),\n func=self._control_device_for_tool, # Wrapper function below\n args_schema=self.ToolSchema,\n )\n\n def _control_device_for_tool(self, action: str, entity_id: str) -> dict[str, Any] | str:\n \"\"\"Function called by the agent.\n\n -> Internally calls _control_device.\n \"\"\"\n return self._control_device(\n ha_token=self.ha_token,\n base_url=self.base_url,\n action=action,\n entity_id=entity_id,\n )\n\n def _control_device(\n self,\n ha_token: str,\n base_url: str,\n action: str,\n entity_id: str,\n ) -> dict[str, Any] | str:\n \"\"\"Actual logic to call the Home Assistant service.\n\n The domain is extracted from the beginning of the entity_id.\n Example: entity_id=\"switch.unknown_switch_3\" -> domain=\"switch\".\n \"\"\"\n try:\n domain = entity_id.split(\".\")[0] # switch, light, cover, etc.\n url = f\"{base_url}/api/services/{domain}/{action}\"\n\n headers = {\n \"Authorization\": f\"Bearer {ha_token}\",\n \"Content-Type\": \"application/json\",\n }\n payload = {\"entity_id\": entity_id}\n\n response = ssrf_safe_httpx_post(url, headers=headers, json=payload, timeout=10)\n response.raise_for_status()\n\n return response.json() # HA response JSON on success\n except SSRFProtectionError as e:\n return f\"Error: SSRF Protection: {e}\"\n except httpx.HTTPError as e:\n return f\"Error: Failed to call service. {e}\"\n except Exception as e: # noqa: BLE001\n return f\"An unexpected error occurred: {e}\"\n\n def _make_data_response(self, result: dict[str, Any] | str) -> Data:\n \"\"\"Returns a response in the LangFlow Data format.\"\"\"\n if isinstance(result, str):\n # Handle error messages\n return Data(text=result)\n\n # Convert dict to JSON string\n formatted_json = json.dumps(result, indent=2, ensure_ascii=False)\n return Data(data=result, text=formatted_json)\n"
},
"default_action": {
"_input_type": "StrInput",
@@ -76585,12 +76581,12 @@
"icon": "HomeAssistant",
"legacy": false,
"metadata": {
- "code_hash": "b292d5203342",
+ "code_hash": "5cc87e9d9fe3",
"dependencies": {
"dependencies": [
{
- "name": "requests",
- "version": "2.34.2"
+ "name": "httpx",
+ "version": "0.28.1"
},
{
"name": "langchain_core",
@@ -76681,7 +76677,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import json\nfrom typing import Any\n\nimport requests\nfrom langchain_core.tools import StructuredTool\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 SecretStrInput, StrInput\nfrom lfx.schema.data import Data\n\n\nclass ListHomeAssistantStates(LCToolComponent):\n display_name: str = \"List Home Assistant States\"\n description: str = (\n \"Retrieve states from Home Assistant. \"\n \"The agent only needs to specify 'filter_domain' (optional). \"\n \"Token and base_url are not exposed to the agent.\"\n )\n documentation: str = \"https://developers.home-assistant.io/docs/api/rest/\"\n icon = \"HomeAssistant\"\n\n # 1) Define fields to be received in LangFlow UI\n inputs = [\n SecretStrInput(\n name=\"ha_token\",\n display_name=\"Home Assistant Token\",\n info=\"Home Assistant Long-Lived Access Token\",\n required=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Home Assistant URL\",\n info=\"e.g., http://192.168.0.10:8123\",\n required=True,\n ),\n StrInput(\n name=\"filter_domain\",\n display_name=\"Default Filter Domain (Optional)\",\n info=\"light, switch, sensor, etc. (Leave empty to fetch all)\",\n required=False,\n ),\n ]\n\n # 2) Pydantic schema containing only parameters exposed to the agent\n class ToolSchema(BaseModel):\n \"\"\"Parameters to be passed by the agent: filter_domain only.\"\"\"\n\n filter_domain: str = Field(\"\", description=\"Filter domain (e.g., 'light'). If empty, returns all.\")\n\n def run_model(self) -> Data:\n \"\"\"Execute the LangFlow component.\n\n Uses self.ha_token, self.base_url, self.filter_domain as entered in the UI.\n Triggered when 'Run' is clicked directly without an agent.\n \"\"\"\n filter_domain = self.filter_domain or \"\" # Use \"\" for fetching all states\n result = self._list_states(\n ha_token=self.ha_token,\n base_url=self.base_url,\n filter_domain=filter_domain,\n )\n return self._make_data_response(result)\n\n def build_tool(self) -> Tool:\n \"\"\"Build a tool object to be used by the agent.\n\n The agent can only pass 'filter_domain' as a parameter.\n 'ha_token' and 'base_url' are not exposed (stored as self attributes).\n \"\"\"\n return StructuredTool.from_function(\n name=\"list_homeassistant_states\",\n description=(\n \"Retrieve states from Home Assistant. \"\n \"You can provide filter_domain='light', 'switch', etc. to narrow results.\"\n ),\n func=self._list_states_for_tool, # Wrapper function below\n args_schema=self.ToolSchema, # Requires only filter_domain\n )\n\n def _list_states_for_tool(self, filter_domain: str = \"\") -> list[Any] | str:\n \"\"\"Execute the tool when called by the agent.\n\n 'ha_token' and 'base_url' are stored in self (not exposed).\n \"\"\"\n return self._list_states(\n ha_token=self.ha_token,\n base_url=self.base_url,\n filter_domain=filter_domain,\n )\n\n def _list_states(\n self,\n ha_token: str,\n base_url: str,\n filter_domain: str = \"\",\n ) -> list[Any] | str:\n \"\"\"Call the Home Assistant /api/states endpoint.\"\"\"\n try:\n headers = {\n \"Authorization\": f\"Bearer {ha_token}\",\n \"Content-Type\": \"application/json\",\n }\n url = f\"{base_url}/api/states\"\n response = requests.get(url, headers=headers, timeout=10)\n response.raise_for_status()\n\n all_states = response.json()\n if filter_domain:\n return [st for st in all_states if st.get(\"entity_id\", \"\").startswith(f\"{filter_domain}.\")]\n\n except requests.exceptions.RequestException as e:\n return f\"Error: Failed to fetch states. {e}\"\n except (ValueError, TypeError) as e:\n return f\"Error processing response: {e}\"\n return all_states\n\n def _make_data_response(self, result: list[Any] | str | dict) -> Data:\n \"\"\"Format the response into a Data object.\"\"\"\n try:\n if isinstance(result, list):\n # Wrap list data into a dictionary and convert to text\n wrapped_result = {\"result\": result}\n return Data(data=wrapped_result, text=json.dumps(wrapped_result, indent=2, ensure_ascii=False))\n if isinstance(result, dict):\n # Return dictionary as-is\n return Data(data=result, text=json.dumps(result, indent=2, ensure_ascii=False))\n if isinstance(result, str):\n # Return error messages or strings\n return Data(data={}, text=result)\n\n # Handle unexpected data types\n return Data(data={}, text=\"Error: Unexpected response format.\")\n except (TypeError, ValueError) as e:\n # Handle specific exceptions during formatting\n return Data(data={}, text=f\"Error: Failed to process response. Details: {e!s}\")\n"
+ "value": "import json\nfrom typing import Any\n\nimport httpx\nfrom langchain_core.tools import StructuredTool\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 SecretStrInput, StrInput\nfrom lfx.schema.data import Data\nfrom lfx.utils.ssrf_httpx import ssrf_safe_httpx_get\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\n\n\nclass ListHomeAssistantStates(LCToolComponent):\n display_name: str = \"List Home Assistant States\"\n description: str = (\n \"Retrieve states from Home Assistant. \"\n \"The agent only needs to specify 'filter_domain' (optional). \"\n \"Token and base_url are not exposed to the agent.\"\n )\n documentation: str = \"https://developers.home-assistant.io/docs/api/rest/\"\n icon = \"HomeAssistant\"\n\n # 1) Define fields to be received in LangFlow UI\n inputs = [\n SecretStrInput(\n name=\"ha_token\",\n display_name=\"Home Assistant Token\",\n info=\"Home Assistant Long-Lived Access Token\",\n required=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Home Assistant URL\",\n info=\"e.g., http://192.168.0.10:8123\",\n required=True,\n ),\n StrInput(\n name=\"filter_domain\",\n display_name=\"Default Filter Domain (Optional)\",\n info=\"light, switch, sensor, etc. (Leave empty to fetch all)\",\n required=False,\n ),\n ]\n\n # 2) Pydantic schema containing only parameters exposed to the agent\n class ToolSchema(BaseModel):\n \"\"\"Parameters to be passed by the agent: filter_domain only.\"\"\"\n\n filter_domain: str = Field(\"\", description=\"Filter domain (e.g., 'light'). If empty, returns all.\")\n\n def run_model(self) -> Data:\n \"\"\"Execute the LangFlow component.\n\n Uses self.ha_token, self.base_url, self.filter_domain as entered in the UI.\n Triggered when 'Run' is clicked directly without an agent.\n \"\"\"\n filter_domain = self.filter_domain or \"\" # Use \"\" for fetching all states\n result = self._list_states(\n ha_token=self.ha_token,\n base_url=self.base_url,\n filter_domain=filter_domain,\n )\n return self._make_data_response(result)\n\n def build_tool(self) -> Tool:\n \"\"\"Build a tool object to be used by the agent.\n\n The agent can only pass 'filter_domain' as a parameter.\n 'ha_token' and 'base_url' are not exposed (stored as self attributes).\n \"\"\"\n return StructuredTool.from_function(\n name=\"list_homeassistant_states\",\n description=(\n \"Retrieve states from Home Assistant. \"\n \"You can provide filter_domain='light', 'switch', etc. to narrow results.\"\n ),\n func=self._list_states_for_tool, # Wrapper function below\n args_schema=self.ToolSchema, # Requires only filter_domain\n )\n\n def _list_states_for_tool(self, filter_domain: str = \"\") -> list[Any] | str:\n \"\"\"Execute the tool when called by the agent.\n\n 'ha_token' and 'base_url' are stored in self (not exposed).\n \"\"\"\n return self._list_states(\n ha_token=self.ha_token,\n base_url=self.base_url,\n filter_domain=filter_domain,\n )\n\n def _list_states(\n self,\n ha_token: str,\n base_url: str,\n filter_domain: str = \"\",\n ) -> list[Any] | str:\n \"\"\"Call the Home Assistant /api/states endpoint.\"\"\"\n try:\n headers = {\n \"Authorization\": f\"Bearer {ha_token}\",\n \"Content-Type\": \"application/json\",\n }\n url = f\"{base_url}/api/states\"\n response = ssrf_safe_httpx_get(url, headers=headers, timeout=10)\n response.raise_for_status()\n\n all_states = response.json()\n if filter_domain:\n return [st for st in all_states if st.get(\"entity_id\", \"\").startswith(f\"{filter_domain}.\")]\n\n except SSRFProtectionError as e:\n return f\"Error: SSRF Protection: {e}\"\n except httpx.HTTPError as e:\n return f\"Error: Failed to fetch states. {e}\"\n except (ValueError, TypeError) as e:\n return f\"Error processing response: {e}\"\n return all_states\n\n def _make_data_response(self, result: list[Any] | str | dict) -> Data:\n \"\"\"Format the response into a Data object.\"\"\"\n try:\n if isinstance(result, list):\n # Wrap list data into a dictionary and convert to text\n wrapped_result = {\"result\": result}\n return Data(data=wrapped_result, text=json.dumps(wrapped_result, indent=2, ensure_ascii=False))\n if isinstance(result, dict):\n # Return dictionary as-is\n return Data(data=result, text=json.dumps(result, indent=2, ensure_ascii=False))\n if isinstance(result, str):\n # Return error messages or strings\n return Data(data={}, text=result)\n\n # Handle unexpected data types\n return Data(data={}, text=\"Error: Unexpected response format.\")\n except (TypeError, ValueError) as e:\n # Handle specific exceptions during formatting\n return Data(data={}, text=f\"Error: Failed to process response. Details: {e!s}\")\n"
},
"filter_domain": {
"_input_type": "StrInput",
@@ -76751,12 +76747,12 @@
"icon": "HuggingFace",
"legacy": false,
"metadata": {
- "code_hash": "af0546658974",
+ "code_hash": "ead4de3ab276",
"dependencies": {
"dependencies": [
{
- "name": "requests",
- "version": "2.34.2"
+ "name": "httpx",
+ "version": "0.28.1"
},
{
"name": "langchain_community",
@@ -76835,7 +76831,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "from urllib.parse import urlparse\n\nimport requests\nfrom langchain_community.embeddings.huggingface import HuggingFaceInferenceAPIEmbeddings\n\n# Next update: use langchain_huggingface\nfrom pydantic import SecretStr\nfrom tenacity import retry, stop_after_attempt, wait_fixed\n\nfrom lfx.base.embeddings.model import LCEmbeddingsModel\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import MessageTextInput, Output, SecretStrInput\n\n\nclass HuggingFaceInferenceAPIEmbeddingsComponent(LCEmbeddingsModel):\n display_name = \"Hugging Face Embeddings Inference\"\n description = \"Generate embeddings using Hugging Face Text Embeddings Inference (TEI)\"\n documentation = \"https://huggingface.co/docs/text-embeddings-inference/index\"\n icon = \"HuggingFace\"\n name = \"HuggingFaceInferenceAPIEmbeddings\"\n\n inputs = [\n SecretStrInput(\n name=\"api_key\",\n display_name=\"HuggingFace API Key\",\n advanced=False,\n info=\"Required for non-local inference endpoints. Local inference does not require an API Key.\",\n ),\n MessageTextInput(\n name=\"inference_endpoint\",\n display_name=\"Inference Endpoint\",\n required=True,\n value=\"https://api-inference.huggingface.co/models/\",\n info=\"Custom inference endpoint URL.\",\n ),\n MessageTextInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n value=\"BAAI/bge-large-en-v1.5\",\n info=\"The name of the model to use for text embeddings.\",\n required=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Embeddings\", name=\"embeddings\", method=\"build_embeddings\"),\n ]\n\n def validate_inference_endpoint(self, inference_endpoint: str) -> bool:\n parsed_url = urlparse(inference_endpoint)\n if not all([parsed_url.scheme, parsed_url.netloc]):\n msg = (\n f\"Invalid inference endpoint format: '{self.inference_endpoint}'. \"\n \"Please ensure the URL includes both a scheme (e.g., 'http://' or 'https://') and a domain name. \"\n \"Example: 'http://localhost:8080' or 'https://api.example.com'\"\n )\n raise ValueError(msg)\n\n try:\n response = requests.get(f\"{inference_endpoint}/health\", timeout=5)\n except requests.RequestException as e:\n msg = (\n f\"Inference endpoint '{inference_endpoint}' is not responding. \"\n \"Please ensure the URL is correct and the service is running.\"\n )\n raise ValueError(msg) from e\n\n if response.status_code != requests.codes.ok:\n msg = f\"Hugging Face health check failed: {response.status_code}\"\n raise ValueError(msg)\n # returning True to solve linting error\n return True\n\n def get_api_url(self) -> str:\n if \"huggingface\" in self.inference_endpoint.lower():\n return f\"{self.inference_endpoint}\"\n return self.inference_endpoint\n\n @retry(stop=stop_after_attempt(3), wait=wait_fixed(2))\n def create_huggingface_embeddings(\n self, api_key: SecretStr, api_url: str, model_name: str\n ) -> HuggingFaceInferenceAPIEmbeddings:\n return HuggingFaceInferenceAPIEmbeddings(api_key=api_key, api_url=api_url, model_name=model_name)\n\n def build_embeddings(self) -> Embeddings:\n api_url = self.get_api_url()\n\n is_local_url = (\n api_url.startswith((\"http://localhost\", \"http://127.0.0.1\", \"http://0.0.0.0\", \"http://docker\"))\n or \"huggingface.co\" not in api_url.lower()\n )\n\n if not self.api_key and is_local_url:\n self.validate_inference_endpoint(api_url)\n api_key = SecretStr(\"APIKeyForLocalDeployment\")\n elif not self.api_key:\n msg = \"API Key is required for non-local inference endpoints\"\n raise ValueError(msg)\n else:\n api_key = SecretStr(self.api_key).get_secret_value()\n\n try:\n return self.create_huggingface_embeddings(api_key, api_url, self.model_name)\n except Exception as e:\n msg = \"Could not connect to Hugging Face Inference API.\"\n raise ValueError(msg) from e\n"
+ "value": "from urllib.parse import urlparse\n\nimport httpx\nfrom langchain_community.embeddings.huggingface import HuggingFaceInferenceAPIEmbeddings\n\n# Next update: use langchain_huggingface\nfrom pydantic import SecretStr\nfrom tenacity import retry, stop_after_attempt, wait_fixed\n\nfrom lfx.base.embeddings.model import LCEmbeddingsModel\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import MessageTextInput, Output, SecretStrInput\nfrom lfx.utils.ssrf_httpx import ssrf_safe_httpx_get, validate_url_for_ssrf_or_raise\n\n\nclass HuggingFaceInferenceAPIEmbeddingsComponent(LCEmbeddingsModel):\n display_name = \"Hugging Face Embeddings Inference\"\n description = \"Generate embeddings using Hugging Face Text Embeddings Inference (TEI)\"\n documentation = \"https://huggingface.co/docs/text-embeddings-inference/index\"\n icon = \"HuggingFace\"\n name = \"HuggingFaceInferenceAPIEmbeddings\"\n\n inputs = [\n SecretStrInput(\n name=\"api_key\",\n display_name=\"HuggingFace API Key\",\n advanced=False,\n info=\"Required for non-local inference endpoints. Local inference does not require an API Key.\",\n ),\n MessageTextInput(\n name=\"inference_endpoint\",\n display_name=\"Inference Endpoint\",\n required=True,\n value=\"https://api-inference.huggingface.co/models/\",\n info=\"Custom inference endpoint URL.\",\n ),\n MessageTextInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n value=\"BAAI/bge-large-en-v1.5\",\n info=\"The name of the model to use for text embeddings.\",\n required=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Embeddings\", name=\"embeddings\", method=\"build_embeddings\"),\n ]\n\n def validate_inference_endpoint(self, inference_endpoint: str) -> bool:\n parsed_url = urlparse(inference_endpoint)\n if not all([parsed_url.scheme, parsed_url.netloc]):\n msg = (\n f\"Invalid inference endpoint format: '{self.inference_endpoint}'. \"\n \"Please ensure the URL includes both a scheme (e.g., 'http://' or 'https://') and a domain name. \"\n \"Example: 'http://localhost:8080' or 'https://api.example.com'\"\n )\n raise ValueError(msg)\n\n try:\n response = ssrf_safe_httpx_get(f\"{inference_endpoint}/health\", timeout=5)\n except httpx.HTTPError as e:\n msg = (\n f\"Inference endpoint '{inference_endpoint}' is not responding. \"\n \"Please ensure the URL is correct and the service is running.\"\n )\n raise ValueError(msg) from e\n\n if response.status_code != httpx.codes.OK:\n msg = f\"Hugging Face health check failed: {response.status_code}\"\n raise ValueError(msg)\n # returning True to solve linting error\n return True\n\n def get_api_url(self) -> str:\n if \"huggingface\" in self.inference_endpoint.lower():\n return f\"{self.inference_endpoint}\"\n return self.inference_endpoint\n\n @retry(stop=stop_after_attempt(3), wait=wait_fixed(2))\n def create_huggingface_embeddings(\n self, api_key: SecretStr, api_url: str, model_name: str\n ) -> HuggingFaceInferenceAPIEmbeddings:\n return HuggingFaceInferenceAPIEmbeddings(api_key=api_key, api_url=api_url, model_name=model_name)\n\n def build_embeddings(self) -> Embeddings:\n api_url = self.get_api_url()\n validate_url_for_ssrf_or_raise(api_url)\n\n is_local_url = (\n api_url.startswith((\"http://localhost\", \"http://127.0.0.1\", \"http://0.0.0.0\", \"http://docker\"))\n or \"huggingface.co\" not in api_url.lower()\n )\n\n if not self.api_key and is_local_url:\n self.validate_inference_endpoint(api_url)\n api_key = SecretStr(\"APIKeyForLocalDeployment\")\n elif not self.api_key:\n msg = \"API Key is required for non-local inference endpoints\"\n raise ValueError(msg)\n else:\n api_key = SecretStr(self.api_key).get_secret_value()\n\n try:\n return self.create_huggingface_embeddings(api_key, api_url, self.model_name)\n except Exception as e:\n msg = \"Could not connect to Hugging Face Inference API.\"\n raise ValueError(msg) from e\n"
},
"inference_endpoint": {
"_input_type": "MessageTextInput",
@@ -86815,7 +86811,7 @@
"icon": "LiteLLM",
"legacy": false,
"metadata": {
- "code_hash": "1ed792de48a9",
+ "code_hash": "ee386e516eb4",
"dependencies": {
"dependencies": [
{
@@ -86936,7 +86932,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import httpx\nfrom langchain_openai import ChatOpenAI\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.inputs.inputs import IntInput, SecretStrInput, SliderInput, StrInput\nfrom lfx.utils.secrets import secret_value_to_str\n\n\nclass LiteLLMProxyComponent(LCModelComponent):\n \"\"\"LiteLLM Proxy component for routing to multiple LLM providers.\"\"\"\n\n display_name = \"LiteLLM Proxy\"\n description = \"Generate text using any LLM provider via a LiteLLM proxy with virtual key authentication.\"\n icon = \"LiteLLM\"\n name = \"LiteLLMProxyModel\"\n\n inputs = [\n *LCModelComponent.get_base_inputs(),\n StrInput(\n name=\"api_base\",\n display_name=\"LiteLLM Proxy URL\",\n value=\"http://localhost:4000/v1\",\n required=True,\n info=\"Base URL of the LiteLLM proxy.\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Virtual Key\",\n value=\"LITELLM_API_KEY\",\n required=True,\n info=\"Virtual key for authentication.\",\n ),\n StrInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n required=True,\n info=\"Model name to use (e.g. gpt-4o, claude-3-opus).\",\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.7,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n info=\"Controls randomness. Lower values are more deterministic.\",\n ),\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"Maximum number of tokens to generate. Set to 0 for no limit.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout (seconds)\",\n value=60,\n advanced=True,\n info=\"Request timeout in seconds.\",\n ),\n IntInput(\n name=\"max_retries\",\n display_name=\"Max Retries\",\n value=2,\n advanced=True,\n info=\"Maximum number of retries on failure.\",\n ),\n ]\n\n def build_model(self) -> LanguageModel:\n \"\"\"Build the LiteLLM proxy model.\"\"\"\n api_key = secret_value_to_str(self.api_key) or \"\"\n\n self._validate_proxy_connection(api_key)\n\n return ChatOpenAI(\n base_url=self.api_base,\n api_key=api_key,\n model=self.model_name,\n temperature=self.temperature,\n max_tokens=self.max_tokens if self.max_tokens != 0 else None,\n timeout=self.timeout,\n max_retries=self.max_retries,\n streaming=self.stream,\n )\n\n def _validate_proxy_connection(self, api_key: str) -> None:\n \"\"\"Validate the proxy connection, API key, and model availability.\"\"\"\n base_url = self.api_base.rstrip(\"/\")\n models_url = f\"{base_url}/models\"\n\n try:\n response = httpx.get(\n models_url,\n headers={\"Authorization\": f\"Bearer {api_key}\"},\n timeout=10,\n )\n except httpx.ConnectError as e:\n msg = (\n f\"Could not connect to LiteLLM Proxy at {base_url}. Verify the URL is correct and the proxy is running.\"\n )\n raise ValueError(msg) from e\n except httpx.TimeoutException as e:\n msg = f\"Connection to LiteLLM Proxy at {base_url} timed out.\"\n raise ValueError(msg) from e\n\n http_unauthorized = 401\n if response.status_code == http_unauthorized:\n msg = \"Authentication failed. Check that your Virtual Key is valid and not expired.\"\n raise ValueError(msg)\n\n response.raise_for_status()\n\n data = response.json()\n available_models = [m.get(\"id\", \"\") for m in data.get(\"data\", [])]\n if available_models and self.model_name not in available_models:\n msg = (\n f\"Model '{self.model_name}' not found on the LiteLLM Proxy. \"\n f\"Available models: {', '.join(available_models)}\"\n )\n raise ValueError(msg)\n\n def _get_exception_message(self, e: Exception) -> str | None:\n \"\"\"Extract meaningful error messages from OpenAI client exceptions.\"\"\"\n try:\n from openai import AuthenticationError, BadRequestError, NotFoundError\n except ImportError:\n return None\n\n if isinstance(e, AuthenticationError):\n return \"Authentication failed. Check that your Virtual Key is valid and not expired.\"\n if isinstance(e, NotFoundError):\n return f\"Model '{self.model_name}' not found. Verify the model name.\"\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\") if isinstance(e.body, dict) else None\n if message:\n return message\n return None\n"
+ "value": "import httpx\nfrom langchain_openai import ChatOpenAI\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.inputs.inputs import IntInput, SecretStrInput, SliderInput, StrInput\nfrom lfx.utils.secrets import secret_value_to_str\nfrom lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_httpx_get\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\n\n\nclass LiteLLMProxyComponent(LCModelComponent):\n \"\"\"LiteLLM Proxy component for routing to multiple LLM providers.\"\"\"\n\n display_name = \"LiteLLM Proxy\"\n description = \"Generate text using any LLM provider via a LiteLLM proxy with virtual key authentication.\"\n icon = \"LiteLLM\"\n name = \"LiteLLMProxyModel\"\n\n inputs = [\n *LCModelComponent.get_base_inputs(),\n StrInput(\n name=\"api_base\",\n display_name=\"LiteLLM Proxy URL\",\n value=\"http://localhost:4000/v1\",\n required=True,\n info=\"Base URL of the LiteLLM proxy.\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Virtual Key\",\n value=\"LITELLM_API_KEY\",\n required=True,\n info=\"Virtual key for authentication.\",\n ),\n StrInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n required=True,\n info=\"Model name to use (e.g. gpt-4o, claude-3-opus).\",\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.7,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n info=\"Controls randomness. Lower values are more deterministic.\",\n ),\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"Maximum number of tokens to generate. Set to 0 for no limit.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n IntInput(\n name=\"timeout\",\n display_name=\"Timeout (seconds)\",\n value=60,\n advanced=True,\n info=\"Request timeout in seconds.\",\n ),\n IntInput(\n name=\"max_retries\",\n display_name=\"Max Retries\",\n value=2,\n advanced=True,\n info=\"Maximum number of retries on failure.\",\n ),\n ]\n\n def build_model(self) -> LanguageModel:\n \"\"\"Build the LiteLLM proxy model.\"\"\"\n api_key = secret_value_to_str(self.api_key) or \"\"\n ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(self.api_base)\n\n self._validate_proxy_connection(api_key)\n\n return ChatOpenAI(\n base_url=self.api_base,\n api_key=api_key,\n model=self.model_name,\n temperature=self.temperature,\n max_tokens=self.max_tokens if self.max_tokens != 0 else None,\n timeout=self.timeout,\n max_retries=self.max_retries,\n streaming=self.stream,\n **ssrf_client_kwargs,\n )\n\n def _validate_proxy_connection(self, api_key: str) -> None:\n \"\"\"Validate the proxy connection, API key, and model availability.\"\"\"\n base_url = self.api_base.rstrip(\"/\")\n models_url = f\"{base_url}/models\"\n\n try:\n response = ssrf_safe_httpx_get(\n models_url,\n headers={\"Authorization\": f\"Bearer {api_key}\"},\n timeout=10,\n )\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except httpx.ConnectError as e:\n msg = (\n f\"Could not connect to LiteLLM Proxy at {base_url}. Verify the URL is correct and the proxy is running.\"\n )\n raise ValueError(msg) from e\n except httpx.TimeoutException as e:\n msg = f\"Connection to LiteLLM Proxy at {base_url} timed out.\"\n raise ValueError(msg) from e\n\n http_unauthorized = 401\n if response.status_code == http_unauthorized:\n msg = \"Authentication failed. Check that your Virtual Key is valid and not expired.\"\n raise ValueError(msg)\n\n response.raise_for_status()\n\n data = response.json()\n available_models = [m.get(\"id\", \"\") for m in data.get(\"data\", [])]\n if available_models and self.model_name not in available_models:\n msg = (\n f\"Model '{self.model_name}' not found on the LiteLLM Proxy. \"\n f\"Available models: {', '.join(available_models)}\"\n )\n raise ValueError(msg)\n\n def _get_exception_message(self, e: Exception) -> str | None:\n \"\"\"Extract meaningful error messages from OpenAI client exceptions.\"\"\"\n try:\n from openai import AuthenticationError, BadRequestError, NotFoundError\n except ImportError:\n return None\n\n if isinstance(e, AuthenticationError):\n return \"Authentication failed. Check that your Virtual Key is valid and not expired.\"\n if isinstance(e, NotFoundError):\n return f\"Model '{self.model_name}' not found. Verify the model name.\"\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\") if isinstance(e.body, dict) else None\n if message:\n return message\n return None\n"
},
"input_value": {
"_input_type": "MessageInput",
@@ -88922,13 +88918,9 @@
"icon": "LMStudio",
"legacy": false,
"metadata": {
- "code_hash": "ecd584b7a486",
+ "code_hash": "79e48a04731b",
"dependencies": {
"dependencies": [
- {
- "name": "httpx",
- "version": "0.28.1"
- },
{
"name": "lfx",
"version": null
@@ -88938,7 +88930,7 @@
"version": "1.0.4"
}
],
- "total_dependencies": 3
+ "total_dependencies": 2
},
"module": "lfx.components.lmstudio.lmstudioembeddings.LMStudioEmbeddingsComponent"
},
@@ -89024,7 +89016,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "from typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\n\nfrom lfx.base.embeddings.model import LCEmbeddingsModel\nfrom lfx.field_typing import Embeddings\nfrom lfx.inputs.inputs import DropdownInput, SecretStrInput\nfrom lfx.io import FloatInput, MessageTextInput\n\n\nclass LMStudioEmbeddingsComponent(LCEmbeddingsModel):\n display_name: str = \"LM Studio Embeddings\"\n description: str = \"Generate embeddings using LM Studio.\"\n icon = \"LMStudio\"\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None): # noqa: ARG002\n if field_name == \"model\":\n base_url_dict = build_config.get(\"base_url\", {})\n base_url_load_from_db = base_url_dict.get(\"load_from_db\", False)\n base_url_value = base_url_dict.get(\"value\")\n if base_url_load_from_db:\n base_url_value = await self.get_variables(base_url_value, field_name)\n elif not base_url_value:\n base_url_value = \"http://localhost:1234/v1\"\n build_config[\"model\"][\"options\"] = await self.get_model(base_url_value)\n\n return build_config\n\n @staticmethod\n async def get_model(base_url_value: str) -> list[str]:\n try:\n url = urljoin(base_url_value, \"/v1/models\")\n async with httpx.AsyncClient() as client:\n response = await client.get(url)\n response.raise_for_status()\n data = response.json()\n\n return [model[\"id\"] for model in data.get(\"data\", [])]\n except Exception as e:\n msg = \"Could not retrieve models. Please, make sure the LM Studio server is running.\"\n raise ValueError(msg) from e\n\n inputs = [\n DropdownInput(\n name=\"model\",\n display_name=\"Model\",\n advanced=False,\n refresh_button=True,\n required=True,\n ),\n MessageTextInput(\n name=\"base_url\",\n display_name=\"LM Studio Base URL\",\n refresh_button=True,\n value=\"http://localhost:1234/v1\",\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"LM Studio API Key\",\n advanced=True,\n value=\"LMSTUDIO_API_KEY\",\n ),\n FloatInput(\n name=\"temperature\",\n display_name=\"Model Temperature\",\n value=0.1,\n advanced=True,\n ),\n ]\n\n def build_embeddings(self) -> Embeddings:\n try:\n from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings\n except ImportError as e:\n msg = \"Please install langchain-nvidia-ai-endpoints to use LM Studio Embeddings.\"\n raise ImportError(msg) from e\n try:\n output = NVIDIAEmbeddings(\n model=self.model,\n base_url=self.base_url,\n temperature=self.temperature,\n nvidia_api_key=self.api_key,\n )\n except Exception as e:\n msg = f\"Could not connect to LM Studio API. Error: {e}\"\n raise ValueError(msg) from e\n return output\n"
+ "value": "from typing import Any\nfrom urllib.parse import urljoin\n\nfrom lfx.base.embeddings.model import LCEmbeddingsModel\nfrom lfx.field_typing import Embeddings\nfrom lfx.inputs.inputs import DropdownInput, SecretStrInput\nfrom lfx.io import FloatInput, MessageTextInput\nfrom lfx.utils.ssrf_httpx import ssrf_safe_async_get, validate_url_for_ssrf_or_raise\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\n\n\nclass LMStudioEmbeddingsComponent(LCEmbeddingsModel):\n display_name: str = \"LM Studio Embeddings\"\n description: str = \"Generate embeddings using LM Studio.\"\n icon = \"LMStudio\"\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None): # noqa: ARG002\n if field_name == \"model\":\n base_url_dict = build_config.get(\"base_url\", {})\n base_url_load_from_db = base_url_dict.get(\"load_from_db\", False)\n base_url_value = base_url_dict.get(\"value\")\n if base_url_load_from_db:\n base_url_value = await self.get_variables(base_url_value, field_name)\n elif not base_url_value:\n base_url_value = \"http://localhost:1234/v1\"\n build_config[\"model\"][\"options\"] = await self.get_model(base_url_value)\n\n return build_config\n\n @staticmethod\n async def get_model(base_url_value: str) -> list[str]:\n try:\n url = urljoin(base_url_value, \"/v1/models\")\n response = await ssrf_safe_async_get(url)\n response.raise_for_status()\n data = response.json()\n\n return [model[\"id\"] for model in data.get(\"data\", [])]\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except Exception as e:\n msg = \"Could not retrieve models. Please, make sure the LM Studio server is running.\"\n raise ValueError(msg) from e\n\n inputs = [\n DropdownInput(\n name=\"model\",\n display_name=\"Model\",\n advanced=False,\n refresh_button=True,\n required=True,\n ),\n MessageTextInput(\n name=\"base_url\",\n display_name=\"LM Studio Base URL\",\n refresh_button=True,\n value=\"http://localhost:1234/v1\",\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"LM Studio API Key\",\n advanced=True,\n value=\"LMSTUDIO_API_KEY\",\n ),\n FloatInput(\n name=\"temperature\",\n display_name=\"Model Temperature\",\n value=0.1,\n advanced=True,\n ),\n ]\n\n def build_embeddings(self) -> Embeddings:\n validate_url_for_ssrf_or_raise(self.base_url)\n\n try:\n from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings\n except ImportError as e:\n msg = \"Please install langchain-nvidia-ai-endpoints to use LM Studio Embeddings.\"\n raise ImportError(msg) from e\n try:\n output = NVIDIAEmbeddings(\n model=self.model,\n base_url=self.base_url,\n temperature=self.temperature,\n nvidia_api_key=self.api_key,\n )\n except Exception as e:\n msg = f\"Could not connect to LM Studio API. Error: {e}\"\n raise ValueError(msg) from e\n return output\n"
},
"model": {
"_input_type": "DropdownInput",
@@ -89102,7 +89094,7 @@
"icon": "LMStudio",
"legacy": false,
"metadata": {
- "code_hash": "da4b3b155dc4",
+ "code_hash": "6f94ff3b389a",
"dependencies": {
"dependencies": [
{
@@ -89223,7 +89215,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "from typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_openai import ChatOpenAI\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.inputs.inputs import DictInput, DropdownInput, FloatInput, IntInput, SecretStrInput, StrInput\n\n\nclass LMStudioModelComponent(LCModelComponent):\n display_name = \"LM Studio\"\n description = \"Generate text using LM Studio Local LLMs.\"\n icon = \"LMStudio\"\n name = \"LMStudioModel\"\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None): # noqa: ARG002\n if field_name == \"model_name\":\n base_url_dict = build_config.get(\"base_url\", {})\n base_url_load_from_db = base_url_dict.get(\"load_from_db\", False)\n base_url_value = base_url_dict.get(\"value\")\n if base_url_load_from_db:\n base_url_value = await self.get_variables(base_url_value, field_name)\n try:\n async with httpx.AsyncClient() as client:\n response = await client.get(urljoin(base_url_value, \"/v1/models\"), timeout=2.0)\n response.raise_for_status()\n except httpx.HTTPError:\n msg = \"Could not access the default LM Studio URL. Please, specify the 'Base URL' field.\"\n self.log(msg)\n return build_config\n build_config[\"model_name\"][\"options\"] = await self.get_model(base_url_value)\n\n return build_config\n\n @staticmethod\n async def get_model(base_url_value: str) -> list[str]:\n try:\n url = urljoin(base_url_value, \"/v1/models\")\n async with httpx.AsyncClient() as client:\n response = await client.get(url)\n response.raise_for_status()\n data = response.json()\n\n return [model[\"id\"] for model in data.get(\"data\", [])]\n except Exception as e:\n msg = \"Could not retrieve models. Please, make sure the LM Studio server is running.\"\n raise ValueError(msg) from e\n\n inputs = [\n *LCModelComponent.get_base_inputs(),\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(name=\"model_kwargs\", display_name=\"Model Kwargs\", advanced=True),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n refresh_button=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Base URL\",\n advanced=False,\n info=\"Endpoint of the LM Studio API. Defaults to 'http://localhost:1234/v1' if not specified.\",\n value=\"http://localhost:1234/v1\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"LM Studio API Key\",\n info=\"The LM Studio API Key to use for LM Studio.\",\n advanced=True,\n value=\"LMSTUDIO_API_KEY\",\n ),\n FloatInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n advanced=True,\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n lmstudio_api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n base_url = self.base_url or \"http://localhost:1234/v1\"\n seed = self.seed\n\n return ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=base_url,\n api_key=lmstudio_api_key,\n temperature=temperature if temperature is not None else 0.1,\n seed=seed,\n )\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an LM Studio exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n"
+ "value": "from typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_openai import ChatOpenAI\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.inputs.inputs import DictInput, DropdownInput, FloatInput, IntInput, SecretStrInput, StrInput\nfrom lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_async_get\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\n\n\nclass LMStudioModelComponent(LCModelComponent):\n display_name = \"LM Studio\"\n description = \"Generate text using LM Studio Local LLMs.\"\n icon = \"LMStudio\"\n name = \"LMStudioModel\"\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None): # noqa: ARG002\n if field_name == \"model_name\":\n base_url_dict = build_config.get(\"base_url\", {})\n base_url_load_from_db = base_url_dict.get(\"load_from_db\", False)\n base_url_value = base_url_dict.get(\"value\")\n if base_url_load_from_db:\n base_url_value = await self.get_variables(base_url_value, field_name)\n try:\n response = await ssrf_safe_async_get(urljoin(base_url_value, \"/v1/models\"), timeout=2.0)\n response.raise_for_status()\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except httpx.HTTPError:\n msg = \"Could not access the default LM Studio URL. Please, specify the 'Base URL' field.\"\n self.log(msg)\n return build_config\n build_config[\"model_name\"][\"options\"] = await self.get_model(base_url_value)\n\n return build_config\n\n @staticmethod\n async def get_model(base_url_value: str) -> list[str]:\n try:\n url = urljoin(base_url_value, \"/v1/models\")\n response = await ssrf_safe_async_get(url)\n response.raise_for_status()\n data = response.json()\n\n return [model[\"id\"] for model in data.get(\"data\", [])]\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except Exception as e:\n msg = \"Could not retrieve models. Please, make sure the LM Studio server is running.\"\n raise ValueError(msg) from e\n\n inputs = [\n *LCModelComponent.get_base_inputs(),\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(name=\"model_kwargs\", display_name=\"Model Kwargs\", advanced=True),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n refresh_button=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Base URL\",\n advanced=False,\n info=\"Endpoint of the LM Studio API. Defaults to 'http://localhost:1234/v1' if not specified.\",\n value=\"http://localhost:1234/v1\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"LM Studio API Key\",\n info=\"The LM Studio API Key to use for LM Studio.\",\n advanced=True,\n value=\"LMSTUDIO_API_KEY\",\n ),\n FloatInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n advanced=True,\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n lmstudio_api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n base_url = self.base_url or \"http://localhost:1234/v1\"\n seed = self.seed\n\n ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(base_url)\n\n return ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=base_url,\n api_key=lmstudio_api_key,\n temperature=temperature if temperature is not None else 0.1,\n seed=seed,\n **ssrf_client_kwargs,\n )\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an LM Studio exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n"
},
"input_value": {
"_input_type": "MessageInput",
@@ -96594,7 +96586,7 @@
"icon": "Ollama",
"legacy": false,
"metadata": {
- "code_hash": "a8c56d0835de",
+ "code_hash": "e42e16033136",
"dependencies": {
"dependencies": [
{
@@ -96698,7 +96690,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import asyncio\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import OllamaEmbeddings\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import DropdownInput, Output, SecretStrInput, StrInput\nfrom lfx.log.logger import logger\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\n\n\nclass OllamaEmbeddingsComponent(LCModelComponent):\n display_name: str = \"Ollama Embeddings\"\n description: str = \"Generate embeddings using Ollama models.\"\n documentation = \"https://python.langchain.com/docs/integrations/text_embedding/ollama\"\n icon = \"Ollama\"\n name = \"OllamaEmbeddings\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n EMBEDDING_CAPABILITY = \"embedding\"\n\n inputs = [\n DropdownInput(\n name=\"model_name\",\n display_name=\"Ollama Model\",\n value=\"\",\n options=[],\n real_time_refresh=True,\n refresh_button=True,\n combobox=True,\n required=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama Base URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n required=True,\n real_time_refresh=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Embeddings\", name=\"embeddings\", method=\"build_embeddings\"),\n ]\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n\n def build_embeddings(self) -> Embeddings:\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Strip /v1 suffix if present\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n llm_params = {\n \"model\": self.model_name,\n \"base_url\": transformed_base_url,\n }\n\n if self.headers:\n llm_params[\"client_kwargs\"] = {\"headers\": self.headers}\n\n try:\n output = OllamaEmbeddings(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n return output\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name in {\"base_url\", \"model_name\"} and not await self.is_valid_ollama_url(self.base_url):\n msg = \"Ollama is not running on the provided base URL. Please start Ollama and try again.\"\n raise ValueError(msg)\n if field_name in {\"model_name\", \"base_url\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n build_config[\"model_name\"][\"options\"] = await self.get_model(base_url_to_check)\n else:\n build_config[\"model_name\"][\"options\"] = []\n\n return build_config\n\n async def get_model(self, base_url_value: str) -> list[str]:\n \"\"\"Get the model names from Ollama.\"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n async with httpx.AsyncClient() as client:\n headers = self.headers\n # Fetch available models\n tags_response = await client.get(url=tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await client.post(url=show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY, [])\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n if self.EMBEDDING_CAPABILITY in capabilities:\n model_ids.append(model_name)\n\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n async with httpx.AsyncClient() as client:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n return (\n await client.get(url=urljoin(url, \"api/tags\"), headers=self.headers)\n ).status_code == HTTP_STATUS_OK\n except httpx.RequestError:\n return False\n"
+ "value": "import asyncio\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import OllamaEmbeddings\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import DropdownInput, Output, SecretStrInput, StrInput\nfrom lfx.log.logger import logger\nfrom lfx.utils.ssrf_httpx import (\n ssrf_protected_httpx_client_kwargs_for_url,\n ssrf_safe_async_get,\n ssrf_safe_async_post,\n)\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\n\n\nclass OllamaEmbeddingsComponent(LCModelComponent):\n display_name: str = \"Ollama Embeddings\"\n description: str = \"Generate embeddings using Ollama models.\"\n documentation = \"https://python.langchain.com/docs/integrations/text_embedding/ollama\"\n icon = \"Ollama\"\n name = \"OllamaEmbeddings\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n EMBEDDING_CAPABILITY = \"embedding\"\n\n inputs = [\n DropdownInput(\n name=\"model_name\",\n display_name=\"Ollama Model\",\n value=\"\",\n options=[],\n real_time_refresh=True,\n refresh_button=True,\n combobox=True,\n required=True,\n ),\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama Base URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n required=True,\n real_time_refresh=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Embeddings\", name=\"embeddings\", method=\"build_embeddings\"),\n ]\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n\n def build_embeddings(self) -> Embeddings:\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Strip /v1 suffix if present\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n sync_client_kwargs, async_client_kwargs = ssrf_protected_httpx_client_kwargs_for_url(transformed_base_url)\n\n llm_params = {\n \"model\": self.model_name,\n \"base_url\": transformed_base_url,\n }\n\n if sync_client_kwargs:\n llm_params[\"sync_client_kwargs\"] = sync_client_kwargs\n if async_client_kwargs:\n llm_params[\"async_client_kwargs\"] = async_client_kwargs\n if self.headers:\n llm_params[\"client_kwargs\"] = {\"headers\": self.headers}\n\n try:\n output = OllamaEmbeddings(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n return output\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name in {\"base_url\", \"model_name\"} and not await self.is_valid_ollama_url(self.base_url):\n msg = \"Ollama is not running on the provided base URL. Please start Ollama and try again.\"\n raise ValueError(msg)\n if field_name in {\"model_name\", \"base_url\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n build_config[\"model_name\"][\"options\"] = await self.get_model(base_url_to_check)\n else:\n build_config[\"model_name\"][\"options\"] = []\n\n return build_config\n\n async def get_model(self, base_url_value: str) -> list[str]:\n \"\"\"Get the model names from Ollama.\"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n headers = self.headers\n # Fetch available models\n tags_response = await ssrf_safe_async_get(tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await ssrf_safe_async_post(show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY, [])\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n if self.EMBEDDING_CAPABILITY in capabilities:\n model_ids.append(model_name)\n\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n response = await ssrf_safe_async_get(urljoin(url, \"api/tags\"), headers=self.headers)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except httpx.RequestError:\n return False\n else:\n return response.status_code == HTTP_STATUS_OK\n"
},
"model_name": {
"_input_type": "DropdownInput",
@@ -96777,7 +96769,7 @@
"icon": "Ollama",
"legacy": false,
"metadata": {
- "code_hash": "2aa7e6ecf48c",
+ "code_hash": "0f49f2dd2972",
"dependencies": {
"dependencies": [
{
@@ -96923,7 +96915,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import asyncio\nimport json\nfrom contextlib import suppress\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import ChatOllama\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.base_model import build_model_from_schema\nfrom lfx.io import (\n BoolInput,\n DictInput,\n DropdownInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n TableInput,\n)\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.table import EditMode\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\nTABLE_ROW_PLACEHOLDER = {\"name\": \"field\", \"description\": \"description of field\", \"type\": \"str\", \"multiple\": \"False\"}\n\n\nclass ChatOllamaComponent(LCModelComponent):\n display_name = \"Ollama\"\n description = \"Generate text using Ollama Local LLMs.\"\n icon = \"Ollama\"\n name = \"OllamaModel\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n DESIRED_CAPABILITY = \"completion\"\n TOOL_CALLING_CAPABILITY = \"tools\"\n\n # Define the table schema for the format input\n TABLE_SCHEMA = [\n {\n \"name\": \"name\",\n \"display_name\": \"Name\",\n \"type\": \"str\",\n \"description\": \"Specify the name of the output field.\",\n \"default\": \"field\",\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"description\",\n \"display_name\": \"Description\",\n \"type\": \"str\",\n \"description\": \"Describe the purpose of the output field.\",\n \"default\": \"description of field\",\n \"edit_mode\": EditMode.POPOVER,\n },\n {\n \"name\": \"type\",\n \"display_name\": \"Type\",\n \"type\": \"str\",\n \"edit_mode\": EditMode.INLINE,\n \"description\": (\"Indicate the data type of the output field (e.g., str, int, float, bool, dict).\"),\n \"options\": [\"str\", \"int\", \"float\", \"bool\", \"dict\"],\n \"default\": \"str\",\n },\n {\n \"name\": \"multiple\",\n \"display_name\": \"As List\",\n \"type\": \"boolean\",\n \"description\": \"Set to True if this output field should be a list of the specified type.\",\n \"edit_mode\": EditMode.INLINE,\n \"options\": [\"True\", \"False\"],\n \"default\": \"False\",\n },\n ]\n default_table_row = {row[\"name\"]: row.get(\"default\", None) for row in TABLE_SCHEMA}\n default_table_row_schema = build_model_from_schema([default_table_row]).model_json_schema()\n\n inputs = [\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama API URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n options=[],\n info=\"Refer to https://ollama.com/library for more models.\",\n refresh_button=True,\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n TableInput(\n name=\"format\",\n display_name=\"Format\",\n info=\"Specify the format of the output.\",\n table_schema=TABLE_SCHEMA,\n value=default_table_row,\n show=False,\n ),\n DictInput(name=\"metadata\", display_name=\"Metadata\", info=\"Metadata to add to the run trace.\", advanced=True),\n DropdownInput(\n name=\"mirostat\",\n display_name=\"Mirostat\",\n options=[\"Disabled\", \"Mirostat\", \"Mirostat 2.0\"],\n info=\"Enable/disable Mirostat sampling for controlling perplexity.\",\n value=\"Disabled\",\n advanced=True,\n real_time_refresh=True,\n ),\n FloatInput(\n name=\"mirostat_eta\",\n display_name=\"Mirostat Eta\",\n info=\"Learning rate for Mirostat algorithm. (Default: 0.1)\",\n advanced=True,\n ),\n FloatInput(\n name=\"mirostat_tau\",\n display_name=\"Mirostat Tau\",\n info=\"Controls the balance between coherence and diversity of the output. (Default: 5.0)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_ctx\",\n display_name=\"Context Window Size\",\n info=\"Size of the context window for generating tokens. (Default: 2048)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_gpu\",\n display_name=\"Number of GPUs\",\n info=\"Number of GPUs to use for computation. (Default: 1 on macOS, 0 to disable)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_thread\",\n display_name=\"Number of Threads\",\n info=\"Number of threads to use during computation. (Default: detected for optimal performance)\",\n advanced=True,\n ),\n IntInput(\n name=\"repeat_last_n\",\n display_name=\"Repeat Last N\",\n info=\"How far back the model looks to prevent repetition. (Default: 64, 0 = disabled, -1 = num_ctx)\",\n advanced=True,\n ),\n FloatInput(\n name=\"repeat_penalty\",\n display_name=\"Repeat Penalty\",\n info=\"Penalty for repetitions in generated text. (Default: 1.1)\",\n advanced=True,\n ),\n FloatInput(name=\"tfs_z\", display_name=\"TFS Z\", info=\"Tail free sampling value. (Default: 1)\", advanced=True),\n IntInput(name=\"timeout\", display_name=\"Timeout\", info=\"Timeout for the request stream.\", advanced=True),\n IntInput(\n name=\"top_k\", display_name=\"Top K\", info=\"Limits token selection to top K. (Default: 40)\", advanced=True\n ),\n FloatInput(name=\"top_p\", display_name=\"Top P\", info=\"Works together with top-k. (Default: 0.9)\", advanced=True),\n BoolInput(\n name=\"enable_verbose_output\",\n display_name=\"Ollama Verbose Output\",\n info=\"Whether to print out response text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"tags\",\n display_name=\"Tags\",\n info=\"Comma-separated list of tags to add to the run trace.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"stop_tokens\",\n display_name=\"Stop Tokens\",\n info=\"Comma-separated list of tokens to signal the model to stop generating text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"system\", display_name=\"System\", info=\"System to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"tool_model_enabled\",\n display_name=\"Tool Model Enabled\",\n info=\"Whether to enable tool calling in the model.\",\n value=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"template\", display_name=\"Template\", info=\"Template to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"enable_structured_output\",\n display_name=\"Enable Structured Output\",\n info=\"Whether to enable structured output in the model.\",\n value=False,\n advanced=False,\n real_time_refresh=True,\n ),\n *LCModelComponent.get_base_inputs(),\n ]\n\n outputs = [\n Output(display_name=\"Text\", name=\"text_output\", method=\"text_response\"),\n Output(display_name=\"Language Model\", name=\"model_output\", method=\"build_model\"),\n Output(display_name=\"JSON\", name=\"data_output\", method=\"build_data_output\"),\n Output(display_name=\"Table\", name=\"dataframe_output\", method=\"build_dataframe_output\"),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n # Mapping mirostat settings to their corresponding values\n mirostat_options = {\"Mirostat\": 1, \"Mirostat 2.0\": 2}\n\n # Default to None for 'Disabled'\n mirostat_value = mirostat_options.get(self.mirostat, None)\n\n # Set mirostat_eta and mirostat_tau to None if mirostat is disabled\n if mirostat_value is None:\n mirostat_eta = None\n mirostat_tau = None\n else:\n mirostat_eta = self.mirostat_eta\n mirostat_tau = self.mirostat_tau\n\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Check if URL contains /v1 suffix (OpenAI-compatible mode)\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n # Strip /v1 suffix and log warning\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n try:\n output_format = self._parse_format_field(self.format) if self.enable_structured_output else None\n except Exception as e:\n msg = f\"Failed to parse the format field: {e}\"\n raise ValueError(msg) from e\n\n # Mapping system settings to their corresponding values\n llm_params = {\n \"base_url\": transformed_base_url,\n \"model\": self.model_name,\n \"mirostat\": mirostat_value,\n \"format\": output_format or None,\n \"metadata\": self.metadata,\n \"tags\": self.tags.split(\",\") if self.tags else None,\n \"mirostat_eta\": mirostat_eta,\n \"mirostat_tau\": mirostat_tau,\n \"num_ctx\": self.num_ctx or None,\n \"num_gpu\": self.num_gpu or None,\n \"num_thread\": self.num_thread or None,\n \"repeat_last_n\": self.repeat_last_n or None,\n \"repeat_penalty\": self.repeat_penalty or None,\n \"temperature\": self.temperature or None,\n \"stop\": self.stop_tokens.split(\",\") if self.stop_tokens else None,\n \"system\": self.system,\n \"tfs_z\": self.tfs_z or None,\n \"timeout\": self.timeout or None,\n \"top_k\": self.top_k or None,\n \"top_p\": self.top_p or None,\n \"verbose\": self.enable_verbose_output or False,\n \"template\": self.template,\n }\n headers = self.headers\n if headers is not None:\n llm_params[\"client_kwargs\"] = {\"headers\": headers}\n\n # Remove parameters with None values\n llm_params = {k: v for k, v in llm_params.items() if v is not None}\n\n try:\n output = ChatOllama(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n\n return output\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n async with httpx.AsyncClient() as client:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n return (\n await client.get(url=urljoin(url, \"api/tags\"), headers=self.headers)\n ).status_code == HTTP_STATUS_OK\n except httpx.RequestError:\n return False\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name == \"enable_structured_output\": # bind enable_structured_output boolean to format show value\n build_config[\"format\"][\"show\"] = field_value\n\n if field_name == \"mirostat\":\n if field_value == \"Disabled\":\n build_config[\"mirostat_eta\"][\"advanced\"] = True\n build_config[\"mirostat_tau\"][\"advanced\"] = True\n build_config[\"mirostat_eta\"][\"value\"] = None\n build_config[\"mirostat_tau\"][\"value\"] = None\n\n else:\n build_config[\"mirostat_eta\"][\"advanced\"] = False\n build_config[\"mirostat_tau\"][\"advanced\"] = False\n\n if field_value == \"Mirostat 2.0\":\n build_config[\"mirostat_eta\"][\"value\"] = 0.2\n build_config[\"mirostat_tau\"][\"value\"] = 10\n else:\n build_config[\"mirostat_eta\"][\"value\"] = 0.1\n build_config[\"mirostat_tau\"][\"value\"] = 5\n\n if field_name in {\"model_name\", \"base_url\", \"tool_model_enabled\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n tool_model_enabled = build_config[\"tool_model_enabled\"].get(\"value\", False) or self.tool_model_enabled\n build_config[\"model_name\"][\"options\"] = await self.get_models(\n base_url_to_check, tool_model_enabled=tool_model_enabled\n )\n else:\n build_config[\"model_name\"][\"options\"] = []\n if field_name == \"keep_alive_flag\":\n if field_value == \"Keep\":\n build_config[\"keep_alive\"][\"value\"] = \"-1\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n elif field_value == \"Immediately\":\n build_config[\"keep_alive\"][\"value\"] = \"0\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n else:\n build_config[\"keep_alive\"][\"advanced\"] = False\n\n return build_config\n\n async def get_models(self, base_url_value: str, *, tool_model_enabled: bool | None = None) -> list[str]:\n \"\"\"Fetches a list of models from the Ollama API suitable for text generation.\n\n Args:\n base_url_value (str): The base URL of the Ollama API.\n tool_model_enabled (bool | None, optional): If True, filters the models further to include\n only those that support tool calling. Defaults to None.\n\n Returns:\n list[str]: A list of model names suitable for text generation. Models are included if:\n - They have the \"completion\" capability, OR\n - The capabilities field is not returned (backwards compatibility with older Ollama versions)\n If `tool_model_enabled` is True, only models with verified \"tools\" capability are included\n (models without capabilities info are excluded in this case).\n\n Raises:\n ValueError: If there is an issue with the API request or response, or if the model\n names cannot be retrieved.\n \"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n async with httpx.AsyncClient() as client:\n headers = self.headers\n # Fetch available models\n tags_response = await client.get(url=tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are NOT embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await client.post(url=show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY)\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n # If capabilities not provided, assume it's a completion model (backwards compatibility\n # with older Ollama versions that don't return capabilities from /api/show)\n if capabilities is None:\n if not tool_model_enabled:\n model_ids.append(model_name)\n # If tool_model_enabled is True but no capabilities info, skip the model\n # since we can't verify tool support\n elif self.DESIRED_CAPABILITY in capabilities and (\n not tool_model_enabled or self.TOOL_CALLING_CAPABILITY in capabilities\n ):\n model_ids.append(model_name)\n\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n def _parse_format_field(self, format_value: Any) -> Any:\n \"\"\"Parse the format field to handle both string and dict inputs.\n\n The format field can be:\n - A simple string like \"json\" (backward compatibility)\n - A JSON string from NestedDictInput that needs parsing\n - A dict/JSON schema (already parsed)\n - None or empty\n\n Args:\n format_value: The raw format value from the input field\n\n Returns:\n Parsed format value as string, dict, or None\n \"\"\"\n if not format_value:\n return None\n\n schema = format_value\n if isinstance(format_value, list):\n schema = build_model_from_schema(format_value).model_json_schema()\n if schema == self.default_table_row_schema:\n return None # the rows are generic placeholder rows\n elif isinstance(format_value, str): # parse as json if string\n with suppress(json.JSONDecodeError): # e.g., literal \"json\" is valid for format field\n schema = json.loads(format_value)\n\n return schema or None\n\n async def _parse_json_response(self) -> Any:\n \"\"\"Parse the JSON response from the model.\n\n This method gets the text response and attempts to parse it as JSON.\n Works with models that have format='json' or a JSON schema set.\n\n Returns:\n Parsed JSON (dict, list, or primitive type)\n\n Raises:\n ValueError: If the response is not valid JSON\n \"\"\"\n message = await self.text_response()\n text = message.text if hasattr(message, \"text\") else str(message)\n\n if not text:\n msg = \"No response from model\"\n raise ValueError(msg)\n\n try:\n return json.loads(text)\n except json.JSONDecodeError as e:\n msg = f\"Invalid JSON response. Ensure model supports JSON output. Error: {e}\"\n raise ValueError(msg) from e\n\n async def build_data_output(self) -> Data:\n \"\"\"Build a Data output from the model's JSON response.\n\n Returns:\n Data: A Data object containing the parsed JSON response\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If the response is already a dict, wrap it in Data\n if isinstance(parsed, dict):\n return Data(data=parsed)\n\n # If it's a list, wrap in a results container\n if isinstance(parsed, list):\n if len(parsed) == 1:\n return Data(data=parsed[0])\n return Data(data={\"results\": parsed})\n\n # For primitive types, wrap in a value container\n return Data(data={\"value\": parsed})\n\n async def build_dataframe_output(self) -> DataFrame:\n \"\"\"Build a DataFrame output from the model's JSON response.\n\n Returns:\n DataFrame: A DataFrame containing the parsed JSON response\n\n Raises:\n ValueError: If the response cannot be converted to a DataFrame\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If it's a list of dicts, convert directly to DataFrame\n if isinstance(parsed, list):\n if not parsed:\n return DataFrame()\n # Ensure all items are dicts for proper DataFrame conversion\n if all(isinstance(item, dict) for item in parsed):\n return DataFrame(parsed)\n msg = \"List items must be dictionaries to convert to DataFrame\"\n raise ValueError(msg)\n\n # If it's a single dict, wrap in a list to create a single-row DataFrame\n if isinstance(parsed, dict):\n return DataFrame([parsed])\n\n # For primitive types, create a single-column DataFrame\n return DataFrame([{\"value\": parsed}])\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n"
+ "value": "import asyncio\nimport json\nfrom contextlib import suppress\nfrom typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import ChatOllama\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.helpers.base_model import build_model_from_schema\nfrom lfx.io import (\n BoolInput,\n DictInput,\n DropdownInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n TableInput,\n)\nfrom lfx.log.logger import logger\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.table import EditMode\nfrom lfx.utils.ssrf_httpx import (\n ssrf_protected_httpx_client_kwargs_for_url,\n ssrf_safe_async_get,\n ssrf_safe_async_post,\n)\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\nTABLE_ROW_PLACEHOLDER = {\"name\": \"field\", \"description\": \"description of field\", \"type\": \"str\", \"multiple\": \"False\"}\n\n\nclass ChatOllamaComponent(LCModelComponent):\n display_name = \"Ollama\"\n description = \"Generate text using Ollama Local LLMs.\"\n icon = \"Ollama\"\n name = \"OllamaModel\"\n\n # Define constants for JSON keys\n JSON_MODELS_KEY = \"models\"\n JSON_NAME_KEY = \"name\"\n JSON_CAPABILITIES_KEY = \"capabilities\"\n DESIRED_CAPABILITY = \"completion\"\n TOOL_CALLING_CAPABILITY = \"tools\"\n\n # Define the table schema for the format input\n TABLE_SCHEMA = [\n {\n \"name\": \"name\",\n \"display_name\": \"Name\",\n \"type\": \"str\",\n \"description\": \"Specify the name of the output field.\",\n \"default\": \"field\",\n \"edit_mode\": EditMode.INLINE,\n },\n {\n \"name\": \"description\",\n \"display_name\": \"Description\",\n \"type\": \"str\",\n \"description\": \"Describe the purpose of the output field.\",\n \"default\": \"description of field\",\n \"edit_mode\": EditMode.POPOVER,\n },\n {\n \"name\": \"type\",\n \"display_name\": \"Type\",\n \"type\": \"str\",\n \"edit_mode\": EditMode.INLINE,\n \"description\": (\"Indicate the data type of the output field (e.g., str, int, float, bool, dict).\"),\n \"options\": [\"str\", \"int\", \"float\", \"bool\", \"dict\"],\n \"default\": \"str\",\n },\n {\n \"name\": \"multiple\",\n \"display_name\": \"As List\",\n \"type\": \"boolean\",\n \"description\": \"Set to True if this output field should be a list of the specified type.\",\n \"edit_mode\": EditMode.INLINE,\n \"options\": [\"True\", \"False\"],\n \"default\": \"False\",\n },\n ]\n default_table_row = {row[\"name\"]: row.get(\"default\", None) for row in TABLE_SCHEMA}\n default_table_row_schema = build_model_from_schema([default_table_row]).model_json_schema()\n\n inputs = [\n StrInput(\n name=\"base_url\",\n display_name=\"Ollama API URL\",\n info=\"Endpoint of the Ollama API. Defaults to http://localhost:11434.\",\n value=\"http://localhost:11434\",\n real_time_refresh=True,\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n options=[],\n info=\"Refer to https://ollama.com/library for more models.\",\n refresh_button=True,\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"Ollama API Key\",\n info=\"Your Ollama API key.\",\n value=None,\n required=False,\n real_time_refresh=True,\n advanced=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=1, step=0.01),\n advanced=True,\n ),\n TableInput(\n name=\"format\",\n display_name=\"Format\",\n info=\"Specify the format of the output.\",\n table_schema=TABLE_SCHEMA,\n value=default_table_row,\n show=False,\n ),\n DictInput(name=\"metadata\", display_name=\"Metadata\", info=\"Metadata to add to the run trace.\", advanced=True),\n DropdownInput(\n name=\"mirostat\",\n display_name=\"Mirostat\",\n options=[\"Disabled\", \"Mirostat\", \"Mirostat 2.0\"],\n info=\"Enable/disable Mirostat sampling for controlling perplexity.\",\n value=\"Disabled\",\n advanced=True,\n real_time_refresh=True,\n ),\n FloatInput(\n name=\"mirostat_eta\",\n display_name=\"Mirostat Eta\",\n info=\"Learning rate for Mirostat algorithm. (Default: 0.1)\",\n advanced=True,\n ),\n FloatInput(\n name=\"mirostat_tau\",\n display_name=\"Mirostat Tau\",\n info=\"Controls the balance between coherence and diversity of the output. (Default: 5.0)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_ctx\",\n display_name=\"Context Window Size\",\n info=\"Size of the context window for generating tokens. (Default: 2048)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_gpu\",\n display_name=\"Number of GPUs\",\n info=\"Number of GPUs to use for computation. (Default: 1 on macOS, 0 to disable)\",\n advanced=True,\n ),\n IntInput(\n name=\"num_thread\",\n display_name=\"Number of Threads\",\n info=\"Number of threads to use during computation. (Default: detected for optimal performance)\",\n advanced=True,\n ),\n IntInput(\n name=\"repeat_last_n\",\n display_name=\"Repeat Last N\",\n info=\"How far back the model looks to prevent repetition. (Default: 64, 0 = disabled, -1 = num_ctx)\",\n advanced=True,\n ),\n FloatInput(\n name=\"repeat_penalty\",\n display_name=\"Repeat Penalty\",\n info=\"Penalty for repetitions in generated text. (Default: 1.1)\",\n advanced=True,\n ),\n FloatInput(name=\"tfs_z\", display_name=\"TFS Z\", info=\"Tail free sampling value. (Default: 1)\", advanced=True),\n IntInput(name=\"timeout\", display_name=\"Timeout\", info=\"Timeout for the request stream.\", advanced=True),\n IntInput(\n name=\"top_k\", display_name=\"Top K\", info=\"Limits token selection to top K. (Default: 40)\", advanced=True\n ),\n FloatInput(name=\"top_p\", display_name=\"Top P\", info=\"Works together with top-k. (Default: 0.9)\", advanced=True),\n BoolInput(\n name=\"enable_verbose_output\",\n display_name=\"Ollama Verbose Output\",\n info=\"Whether to print out response text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"tags\",\n display_name=\"Tags\",\n info=\"Comma-separated list of tags to add to the run trace.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"stop_tokens\",\n display_name=\"Stop Tokens\",\n info=\"Comma-separated list of tokens to signal the model to stop generating text.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"system\", display_name=\"System\", info=\"System to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"tool_model_enabled\",\n display_name=\"Tool Model Enabled\",\n info=\"Whether to enable tool calling in the model.\",\n value=True,\n real_time_refresh=True,\n ),\n MessageTextInput(\n name=\"template\", display_name=\"Template\", info=\"Template to use for generating text.\", advanced=True\n ),\n BoolInput(\n name=\"enable_structured_output\",\n display_name=\"Enable Structured Output\",\n info=\"Whether to enable structured output in the model.\",\n value=False,\n advanced=False,\n real_time_refresh=True,\n ),\n *LCModelComponent.get_base_inputs(),\n ]\n\n outputs = [\n Output(display_name=\"Text\", name=\"text_output\", method=\"text_response\"),\n Output(display_name=\"Language Model\", name=\"model_output\", method=\"build_model\"),\n Output(display_name=\"JSON\", name=\"data_output\", method=\"build_data_output\"),\n Output(display_name=\"Table\", name=\"dataframe_output\", method=\"build_dataframe_output\"),\n ]\n\n def build_model(self) -> LanguageModel: # type: ignore[type-var]\n # Mapping mirostat settings to their corresponding values\n mirostat_options = {\"Mirostat\": 1, \"Mirostat 2.0\": 2}\n\n # Default to None for 'Disabled'\n mirostat_value = mirostat_options.get(self.mirostat, None)\n\n # Set mirostat_eta and mirostat_tau to None if mirostat is disabled\n if mirostat_value is None:\n mirostat_eta = None\n mirostat_tau = None\n else:\n mirostat_eta = self.mirostat_eta\n mirostat_tau = self.mirostat_tau\n\n transformed_base_url = transform_localhost_url(self.base_url)\n\n # Check if URL contains /v1 suffix (OpenAI-compatible mode)\n if transformed_base_url and transformed_base_url.rstrip(\"/\").endswith(\"/v1\"):\n # Strip /v1 suffix and log warning\n transformed_base_url = transformed_base_url.rstrip(\"/\").removesuffix(\"/v1\")\n logger.warning(\n \"Detected '/v1' suffix in base URL. The Ollama component uses the native Ollama API, \"\n \"not the OpenAI-compatible API. The '/v1' suffix has been automatically removed. \"\n \"If you want to use the OpenAI-compatible API, please use the OpenAI component instead. \"\n \"Learn more at https://docs.ollama.com/openai#openai-compatibility\"\n )\n\n sync_client_kwargs, async_client_kwargs = ssrf_protected_httpx_client_kwargs_for_url(transformed_base_url)\n\n try:\n output_format = self._parse_format_field(self.format) if self.enable_structured_output else None\n except Exception as e:\n msg = f\"Failed to parse the format field: {e}\"\n raise ValueError(msg) from e\n\n # Mapping system settings to their corresponding values\n llm_params = {\n \"base_url\": transformed_base_url,\n \"model\": self.model_name,\n \"mirostat\": mirostat_value,\n \"format\": output_format or None,\n \"metadata\": self.metadata,\n \"tags\": self.tags.split(\",\") if self.tags else None,\n \"mirostat_eta\": mirostat_eta,\n \"mirostat_tau\": mirostat_tau,\n \"num_ctx\": self.num_ctx or None,\n \"num_gpu\": self.num_gpu or None,\n \"num_thread\": self.num_thread or None,\n \"repeat_last_n\": self.repeat_last_n or None,\n \"repeat_penalty\": self.repeat_penalty or None,\n \"temperature\": self.temperature or None,\n \"stop\": self.stop_tokens.split(\",\") if self.stop_tokens else None,\n \"system\": self.system,\n \"tfs_z\": self.tfs_z or None,\n \"timeout\": self.timeout or None,\n \"top_k\": self.top_k or None,\n \"top_p\": self.top_p or None,\n \"verbose\": self.enable_verbose_output or False,\n \"template\": self.template,\n }\n headers = self.headers\n if headers is not None:\n llm_params[\"client_kwargs\"] = {\"headers\": headers}\n if sync_client_kwargs:\n llm_params[\"sync_client_kwargs\"] = sync_client_kwargs\n if async_client_kwargs:\n llm_params[\"async_client_kwargs\"] = async_client_kwargs\n\n # Remove parameters with None values\n llm_params = {k: v for k, v in llm_params.items() if v is not None}\n\n try:\n output = ChatOllama(**llm_params)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \"\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\"\n )\n raise ValueError(msg) from e\n\n return output\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n url = transform_localhost_url(url)\n if not url:\n return False\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n url = url.rstrip(\"/\").removesuffix(\"/v1\")\n if not url.endswith(\"/\"):\n url = url + \"/\"\n response = await ssrf_safe_async_get(urljoin(url, \"api/tags\"), headers=self.headers)\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except httpx.RequestError:\n return False\n else:\n return response.status_code == HTTP_STATUS_OK\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name == \"enable_structured_output\": # bind enable_structured_output boolean to format show value\n build_config[\"format\"][\"show\"] = field_value\n\n if field_name == \"mirostat\":\n if field_value == \"Disabled\":\n build_config[\"mirostat_eta\"][\"advanced\"] = True\n build_config[\"mirostat_tau\"][\"advanced\"] = True\n build_config[\"mirostat_eta\"][\"value\"] = None\n build_config[\"mirostat_tau\"][\"value\"] = None\n\n else:\n build_config[\"mirostat_eta\"][\"advanced\"] = False\n build_config[\"mirostat_tau\"][\"advanced\"] = False\n\n if field_value == \"Mirostat 2.0\":\n build_config[\"mirostat_eta\"][\"value\"] = 0.2\n build_config[\"mirostat_tau\"][\"value\"] = 10\n else:\n build_config[\"mirostat_eta\"][\"value\"] = 0.1\n build_config[\"mirostat_tau\"][\"value\"] = 5\n\n if field_name in {\"model_name\", \"base_url\", \"tool_model_enabled\"}:\n # Use field_value if base_url is being updated, otherwise use self.base_url\n base_url_to_check = field_value if field_name == \"base_url\" else self.base_url\n # Fallback to self.base_url if field_value is None or empty\n if not base_url_to_check and field_name == \"base_url\":\n base_url_to_check = self.base_url\n logger.warning(f\"Fetching Ollama models from updated URL: {base_url_to_check}\")\n\n if base_url_to_check and await self.is_valid_ollama_url(base_url_to_check):\n tool_model_enabled = build_config[\"tool_model_enabled\"].get(\"value\", False) or self.tool_model_enabled\n build_config[\"model_name\"][\"options\"] = await self.get_models(\n base_url_to_check, tool_model_enabled=tool_model_enabled\n )\n else:\n build_config[\"model_name\"][\"options\"] = []\n if field_name == \"keep_alive_flag\":\n if field_value == \"Keep\":\n build_config[\"keep_alive\"][\"value\"] = \"-1\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n elif field_value == \"Immediately\":\n build_config[\"keep_alive\"][\"value\"] = \"0\"\n build_config[\"keep_alive\"][\"advanced\"] = True\n else:\n build_config[\"keep_alive\"][\"advanced\"] = False\n\n return build_config\n\n async def get_models(self, base_url_value: str, *, tool_model_enabled: bool | None = None) -> list[str]:\n \"\"\"Fetches a list of models from the Ollama API suitable for text generation.\n\n Args:\n base_url_value (str): The base URL of the Ollama API.\n tool_model_enabled (bool | None, optional): If True, filters the models further to include\n only those that support tool calling. Defaults to None.\n\n Returns:\n list[str]: A list of model names suitable for text generation. Models are included if:\n - They have the \"completion\" capability, OR\n - The capabilities field is not returned (backwards compatibility with older Ollama versions)\n If `tool_model_enabled` is True, only models with verified \"tools\" capability are included\n (models without capabilities info are excluded in this case).\n\n Raises:\n ValueError: If there is an issue with the API request or response, or if the model\n names cannot be retrieved.\n \"\"\"\n try:\n # Strip /v1 suffix if present, as Ollama API endpoints are at root level\n base_url = base_url_value.rstrip(\"/\").removesuffix(\"/v1\")\n if not base_url.endswith(\"/\"):\n base_url = base_url + \"/\"\n base_url = transform_localhost_url(base_url)\n\n # Ollama REST API to return models\n tags_url = urljoin(base_url, \"api/tags\")\n\n # Ollama REST API to return model capabilities\n show_url = urljoin(base_url, \"api/show\")\n\n headers = self.headers\n # Fetch available models\n tags_response = await ssrf_safe_async_get(tags_url, headers=headers)\n tags_response.raise_for_status()\n models = tags_response.json()\n if asyncio.iscoroutine(models):\n models = await models\n await logger.adebug(f\"Available models: {models}\")\n\n # Filter models that are NOT embedding models\n model_ids = []\n for model in models[self.JSON_MODELS_KEY]:\n model_name = model[self.JSON_NAME_KEY]\n await logger.adebug(f\"Checking model: {model_name}\")\n\n payload = {\"model\": model_name}\n show_response = await ssrf_safe_async_post(show_url, json=payload, headers=headers)\n show_response.raise_for_status()\n json_data = show_response.json()\n if asyncio.iscoroutine(json_data):\n json_data = await json_data\n\n capabilities = json_data.get(self.JSON_CAPABILITIES_KEY)\n await logger.adebug(f\"Model: {model_name}, Capabilities: {capabilities}\")\n\n # If capabilities not provided, assume it's a completion model (backwards compatibility\n # with older Ollama versions that don't return capabilities from /api/show)\n if capabilities is None:\n if not tool_model_enabled:\n model_ids.append(model_name)\n # If tool_model_enabled is True but no capabilities info, skip the model\n # since we can't verify tool support\n elif self.DESIRED_CAPABILITY in capabilities and (\n not tool_model_enabled or self.TOOL_CALLING_CAPABILITY in capabilities\n ):\n model_ids.append(model_name)\n\n except SSRFProtectionError as e:\n msg = f\"SSRF Protection: {e}\"\n raise ValueError(msg) from e\n except (httpx.RequestError, ValueError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n def _parse_format_field(self, format_value: Any) -> Any:\n \"\"\"Parse the format field to handle both string and dict inputs.\n\n The format field can be:\n - A simple string like \"json\" (backward compatibility)\n - A JSON string from NestedDictInput that needs parsing\n - A dict/JSON schema (already parsed)\n - None or empty\n\n Args:\n format_value: The raw format value from the input field\n\n Returns:\n Parsed format value as string, dict, or None\n \"\"\"\n if not format_value:\n return None\n\n schema = format_value\n if isinstance(format_value, list):\n schema = build_model_from_schema(format_value).model_json_schema()\n if schema == self.default_table_row_schema:\n return None # the rows are generic placeholder rows\n elif isinstance(format_value, str): # parse as json if string\n with suppress(json.JSONDecodeError): # e.g., literal \"json\" is valid for format field\n schema = json.loads(format_value)\n\n return schema or None\n\n async def _parse_json_response(self) -> Any:\n \"\"\"Parse the JSON response from the model.\n\n This method gets the text response and attempts to parse it as JSON.\n Works with models that have format='json' or a JSON schema set.\n\n Returns:\n Parsed JSON (dict, list, or primitive type)\n\n Raises:\n ValueError: If the response is not valid JSON\n \"\"\"\n message = await self.text_response()\n text = message.text if hasattr(message, \"text\") else str(message)\n\n if not text:\n msg = \"No response from model\"\n raise ValueError(msg)\n\n try:\n return json.loads(text)\n except json.JSONDecodeError as e:\n msg = f\"Invalid JSON response. Ensure model supports JSON output. Error: {e}\"\n raise ValueError(msg) from e\n\n async def build_data_output(self) -> Data:\n \"\"\"Build a Data output from the model's JSON response.\n\n Returns:\n Data: A Data object containing the parsed JSON response\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If the response is already a dict, wrap it in Data\n if isinstance(parsed, dict):\n return Data(data=parsed)\n\n # If it's a list, wrap in a results container\n if isinstance(parsed, list):\n if len(parsed) == 1:\n return Data(data=parsed[0])\n return Data(data={\"results\": parsed})\n\n # For primitive types, wrap in a value container\n return Data(data={\"value\": parsed})\n\n async def build_dataframe_output(self) -> DataFrame:\n \"\"\"Build a DataFrame output from the model's JSON response.\n\n Returns:\n DataFrame: A DataFrame containing the parsed JSON response\n\n Raises:\n ValueError: If the response cannot be converted to a DataFrame\n \"\"\"\n parsed = await self._parse_json_response()\n\n # If it's a list of dicts, convert directly to DataFrame\n if isinstance(parsed, list):\n if not parsed:\n return DataFrame()\n # Ensure all items are dicts for proper DataFrame conversion\n if all(isinstance(item, dict) for item in parsed):\n return DataFrame(parsed)\n msg = \"List items must be dictionaries to convert to DataFrame\"\n raise ValueError(msg)\n\n # If it's a single dict, wrap in a list to create a single-row DataFrame\n if isinstance(parsed, dict):\n return DataFrame([parsed])\n\n # For primitive types, create a single-column DataFrame\n return DataFrame([{\"value\": parsed}])\n\n @property\n def headers(self) -> dict[str, str] | None:\n \"\"\"Get the headers for the Ollama API.\"\"\"\n if self.api_key and self.api_key.strip():\n return {\"Authorization\": f\"Bearer {self.api_key}\"}\n return None\n"
},
"enable_structured_output": {
"_input_type": "BoolInput",
@@ -116360,12 +116352,12 @@
"icon": "xAI",
"legacy": false,
"metadata": {
- "code_hash": "ef0eb1cfadeb",
+ "code_hash": "b5be913c5c68",
"dependencies": {
"dependencies": [
{
- "name": "requests",
- "version": "2.34.2"
+ "name": "httpx",
+ "version": "0.28.1"
},
{
"name": "langchain_openai",
@@ -116493,7 +116485,7 @@
"show": true,
"title_case": false,
"type": "code",
- "value": "import requests\nfrom langchain_openai import ChatOpenAI\nfrom pydantic.v1 import SecretStr\nfrom typing_extensions import override\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.inputs.inputs import (\n BoolInput,\n DictInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n SecretStrInput,\n SliderInput,\n)\n\nXAI_DEFAULT_MODELS = [\"grok-2-latest\"]\n\n\nclass XAIModelComponent(LCModelComponent):\n display_name = \"xAI\"\n description = \"Generates text using xAI models like Grok.\"\n icon = \"xAI\"\n name = \"xAIModel\"\n\n inputs = [\n *LCModelComponent.get_base_inputs(),\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n options=XAI_DEFAULT_MODELS,\n value=XAI_DEFAULT_MODELS[0],\n refresh_button=True,\n combobox=True,\n info=\"The xAI model to use\",\n ),\n MessageTextInput(\n name=\"base_url\",\n display_name=\"xAI API Base\",\n advanced=True,\n info=\"The base URL of the xAI API. Defaults to https://api.x.ai/v1\",\n value=\"https://api.x.ai/v1\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"xAI API Key\",\n info=\"The xAI API Key to use for the model.\",\n advanced=False,\n value=\"XAI_API_KEY\",\n required=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=2, step=0.01),\n advanced=True,\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n ]\n\n def get_models(self) -> list[str]:\n \"\"\"Fetch available models from xAI API.\"\"\"\n if not self.api_key:\n return XAI_DEFAULT_MODELS\n\n base_url = self.base_url or \"https://api.x.ai/v1\"\n url = f\"{base_url}/language-models\"\n headers = {\"Authorization\": f\"Bearer {self.api_key}\", \"Accept\": \"application/json\"}\n\n try:\n response = requests.get(url, headers=headers, timeout=10)\n response.raise_for_status()\n data = response.json()\n\n # Extract model IDs and any aliases\n models = set()\n for model in data.get(\"models\", []):\n models.add(model[\"id\"])\n models.update(model.get(\"aliases\", []))\n\n return sorted(models) if models else XAI_DEFAULT_MODELS\n except requests.RequestException as e:\n self.status = f\"Error fetching models: {e}\"\n return XAI_DEFAULT_MODELS\n\n @override\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Update build configuration with fresh model list when key fields change.\"\"\"\n if field_name in {\"api_key\", \"base_url\", \"model_name\"}:\n models = self.get_models()\n build_config[\"model_name\"][\"options\"] = models\n return build_config\n\n def build_model(self) -> LanguageModel:\n api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n base_url = self.base_url or \"https://api.x.ai/v1\"\n json_mode = self.json_mode\n seed = self.seed\n\n api_key = SecretStr(api_key).get_secret_value() if api_key else None\n\n output = ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=base_url,\n api_key=api_key,\n temperature=temperature if temperature is not None else 0.1,\n seed=seed,\n )\n\n if json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an xAI exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n"
+ "value": "import httpx\nfrom langchain_openai import ChatOpenAI\nfrom pydantic.v1 import SecretStr\nfrom typing_extensions import override\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.field_typing import LanguageModel\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.inputs.inputs import (\n BoolInput,\n DictInput,\n DropdownInput,\n IntInput,\n MessageTextInput,\n SecretStrInput,\n SliderInput,\n)\nfrom lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_httpx_get\nfrom lfx.utils.ssrf_protection import SSRFProtectionError\n\nXAI_DEFAULT_MODELS = [\"grok-2-latest\"]\n\n\nclass XAIModelComponent(LCModelComponent):\n display_name = \"xAI\"\n description = \"Generates text using xAI models like Grok.\"\n icon = \"xAI\"\n name = \"xAIModel\"\n\n inputs = [\n *LCModelComponent.get_base_inputs(),\n IntInput(\n name=\"max_tokens\",\n display_name=\"Max Tokens\",\n advanced=True,\n info=\"The maximum number of tokens to generate. Set to 0 for unlimited tokens.\",\n range_spec=RangeSpec(min=0, max=128000),\n ),\n DictInput(\n name=\"model_kwargs\",\n display_name=\"Model Kwargs\",\n advanced=True,\n info=\"Additional keyword arguments to pass to the model.\",\n ),\n BoolInput(\n name=\"json_mode\",\n display_name=\"JSON Mode\",\n advanced=True,\n info=\"If True, it will output JSON regardless of passing a schema.\",\n ),\n DropdownInput(\n name=\"model_name\",\n display_name=\"Model Name\",\n advanced=False,\n options=XAI_DEFAULT_MODELS,\n value=XAI_DEFAULT_MODELS[0],\n refresh_button=True,\n combobox=True,\n info=\"The xAI model to use\",\n ),\n MessageTextInput(\n name=\"base_url\",\n display_name=\"xAI API Base\",\n advanced=True,\n info=\"The base URL of the xAI API. Defaults to https://api.x.ai/v1\",\n value=\"https://api.x.ai/v1\",\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"xAI API Key\",\n info=\"The xAI API Key to use for the model.\",\n advanced=False,\n value=\"XAI_API_KEY\",\n required=True,\n ),\n SliderInput(\n name=\"temperature\",\n display_name=\"Temperature\",\n value=0.1,\n range_spec=RangeSpec(min=0, max=2, step=0.01),\n advanced=True,\n ),\n IntInput(\n name=\"seed\",\n display_name=\"Seed\",\n info=\"The seed controls the reproducibility of the job.\",\n advanced=True,\n value=1,\n ),\n ]\n\n def get_models(self) -> list[str]:\n \"\"\"Fetch available models from xAI API.\"\"\"\n if not self.api_key:\n return XAI_DEFAULT_MODELS\n\n base_url = self.base_url or \"https://api.x.ai/v1\"\n url = f\"{base_url}/language-models\"\n headers = {\"Authorization\": f\"Bearer {self.api_key}\", \"Accept\": \"application/json\"}\n\n try:\n response = ssrf_safe_httpx_get(url, headers=headers, timeout=10)\n response.raise_for_status()\n data = response.json()\n\n # Extract model IDs and any aliases\n models = set()\n for model in data.get(\"models\", []):\n models.add(model[\"id\"])\n models.update(model.get(\"aliases\", []))\n\n return sorted(models) if models else XAI_DEFAULT_MODELS\n except SSRFProtectionError as e:\n self.status = f\"SSRF Protection: {e}\"\n return XAI_DEFAULT_MODELS\n except httpx.HTTPError as e:\n self.status = f\"Error fetching models: {e}\"\n return XAI_DEFAULT_MODELS\n\n @override\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Update build configuration with fresh model list when key fields change.\"\"\"\n if field_name in {\"api_key\", \"base_url\", \"model_name\"}:\n models = self.get_models()\n build_config[\"model_name\"][\"options\"] = models\n return build_config\n\n def build_model(self) -> LanguageModel:\n api_key = self.api_key\n temperature = self.temperature\n model_name: str = self.model_name\n max_tokens = self.max_tokens\n model_kwargs = self.model_kwargs or {}\n base_url = self.base_url or \"https://api.x.ai/v1\"\n json_mode = self.json_mode\n seed = self.seed\n\n ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(base_url)\n api_key = SecretStr(api_key).get_secret_value() if api_key else None\n\n output = ChatOpenAI(\n max_tokens=max_tokens or None,\n model_kwargs=model_kwargs,\n model=model_name,\n base_url=base_url,\n api_key=api_key,\n temperature=temperature if temperature is not None else 0.1,\n seed=seed,\n **ssrf_client_kwargs,\n )\n\n if json_mode:\n output = output.bind(response_format={\"type\": \"json_object\"})\n\n return output\n\n def _get_exception_message(self, e: Exception):\n \"\"\"Get a message from an xAI exception.\n\n Args:\n e (Exception): The exception to get the message from.\n\n Returns:\n str: The message from the exception.\n \"\"\"\n try:\n from openai import BadRequestError\n except ImportError:\n return None\n if isinstance(e, BadRequestError):\n message = e.body.get(\"message\")\n if message:\n return message\n return None\n"
},
"input_value": {
"_input_type": "MessageInput",
@@ -118475,6 +118467,6 @@
"num_components": 354,
"num_modules": 95
},
- "sha256": "f4b2d4493651d955d44833ff4859a7c452a3b1cb16fe90bd184a32b42d7faf67",
+ "sha256": "d0337af9864fc373eec4952146e477e4f6e6d1912106b8e6e4bd21e642cd645e",
"version": "1.10.1"
}
diff --git a/src/lfx/src/lfx/components/deepseek/deepseek.py b/src/lfx/src/lfx/components/deepseek/deepseek.py
index 8bcc71b81649..33a7a1dc7971 100644
--- a/src/lfx/src/lfx/components/deepseek/deepseek.py
+++ b/src/lfx/src/lfx/components/deepseek/deepseek.py
@@ -1,4 +1,4 @@
-import requests
+import httpx
from pydantic.v1 import SecretStr
from typing_extensions import override
@@ -6,6 +6,8 @@
from lfx.field_typing import LanguageModel
from lfx.field_typing.range_spec import RangeSpec
from lfx.inputs.inputs import BoolInput, DictInput, DropdownInput, IntInput, SecretStrInput, SliderInput, StrInput
+from lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_httpx_get
+from lfx.utils.ssrf_protection import SSRFProtectionError
DEEPSEEK_MODELS = ["deepseek-chat"]
@@ -83,11 +85,14 @@ def get_models(self) -> list[str]:
headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json"}
try:
- response = requests.get(url, headers=headers, timeout=10)
+ response = ssrf_safe_httpx_get(url, headers=headers, timeout=10)
response.raise_for_status()
model_list = response.json()
return [model["id"] for model in model_list.get("data", [])]
- except requests.RequestException as e:
+ except SSRFProtectionError as e:
+ self.status = f"SSRF Protection: {e}"
+ return DEEPSEEK_MODELS
+ except httpx.HTTPError as e:
self.status = f"Error fetching models: {e}"
return DEEPSEEK_MODELS
@@ -106,6 +111,8 @@ def build_model(self) -> LanguageModel:
raise ImportError(msg) from e
api_key = SecretStr(self.api_key).get_secret_value() if self.api_key else None
+ ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(self.api_base)
+
output = ChatOpenAI(
model=self.model_name,
temperature=self.temperature if self.temperature is not None else 0.1,
@@ -115,6 +122,7 @@ def build_model(self) -> LanguageModel:
api_key=api_key,
streaming=self.stream if hasattr(self, "stream") else False,
seed=self.seed,
+ **ssrf_client_kwargs,
)
if self.json_mode:
diff --git a/src/lfx/src/lfx/components/glean/glean_search_api.py b/src/lfx/src/lfx/components/glean/glean_search_api.py
index 4c2c5268ea5a..4b43b559af90 100644
--- a/src/lfx/src/lfx/components/glean/glean_search_api.py
+++ b/src/lfx/src/lfx/components/glean/glean_search_api.py
@@ -2,7 +2,6 @@
from typing import Any
from urllib.parse import urljoin
-import httpx
from langchain_core.tools import StructuredTool, ToolException
from pydantic import BaseModel
from pydantic.v1 import Field
@@ -13,6 +12,7 @@
from lfx.io import Output
from lfx.schema.data import Data
from lfx.schema.dataframe import DataFrame
+from lfx.utils.ssrf_httpx import ssrf_safe_httpx_post
class GleanSearchAPISchema(BaseModel):
@@ -82,7 +82,7 @@ def run(self, query: str, **kwargs: Any) -> list[dict[str, Any]]:
def _search_api_results(self, query: str, **kwargs: Any) -> list[dict[str, Any]]:
request_details = self._prepare_request(query, **kwargs)
- response = httpx.post(
+ response = ssrf_safe_httpx_post(
request_details["url"],
json=request_details["payload"],
headers=request_details["headers"],
diff --git a/src/lfx/src/lfx/components/homeassistant/home_assistant_control.py b/src/lfx/src/lfx/components/homeassistant/home_assistant_control.py
index e198e4251945..e90304c8beb5 100644
--- a/src/lfx/src/lfx/components/homeassistant/home_assistant_control.py
+++ b/src/lfx/src/lfx/components/homeassistant/home_assistant_control.py
@@ -1,7 +1,7 @@
import json
from typing import Any
-import requests
+import httpx
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field
@@ -9,6 +9,8 @@
from lfx.field_typing import Tool
from lfx.inputs.inputs import SecretStrInput, StrInput
from lfx.schema.data import Data
+from lfx.utils.ssrf_httpx import ssrf_safe_httpx_post
+from lfx.utils.ssrf_protection import SSRFProtectionError
class HomeAssistantControl(LCToolComponent):
@@ -132,11 +134,13 @@ def _control_device(
}
payload = {"entity_id": entity_id}
- response = requests.post(url, headers=headers, json=payload, timeout=10)
+ response = ssrf_safe_httpx_post(url, headers=headers, json=payload, timeout=10)
response.raise_for_status()
return response.json() # HA response JSON on success
- except requests.exceptions.RequestException as e:
+ except SSRFProtectionError as e:
+ return f"Error: SSRF Protection: {e}"
+ except httpx.HTTPError as e:
return f"Error: Failed to call service. {e}"
except Exception as e: # noqa: BLE001
return f"An unexpected error occurred: {e}"
diff --git a/src/lfx/src/lfx/components/homeassistant/list_home_assistant_states.py b/src/lfx/src/lfx/components/homeassistant/list_home_assistant_states.py
index 6640935feae2..f3aabfc34ed9 100644
--- a/src/lfx/src/lfx/components/homeassistant/list_home_assistant_states.py
+++ b/src/lfx/src/lfx/components/homeassistant/list_home_assistant_states.py
@@ -1,7 +1,7 @@
import json
from typing import Any
-import requests
+import httpx
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field
@@ -9,6 +9,8 @@
from lfx.field_typing import Tool
from lfx.inputs.inputs import SecretStrInput, StrInput
from lfx.schema.data import Data
+from lfx.utils.ssrf_httpx import ssrf_safe_httpx_get
+from lfx.utils.ssrf_protection import SSRFProtectionError
class ListHomeAssistantStates(LCToolComponent):
@@ -103,14 +105,16 @@ def _list_states(
"Content-Type": "application/json",
}
url = f"{base_url}/api/states"
- response = requests.get(url, headers=headers, timeout=10)
+ response = ssrf_safe_httpx_get(url, headers=headers, timeout=10)
response.raise_for_status()
all_states = response.json()
if filter_domain:
return [st for st in all_states if st.get("entity_id", "").startswith(f"{filter_domain}.")]
- except requests.exceptions.RequestException as e:
+ except SSRFProtectionError as e:
+ return f"Error: SSRF Protection: {e}"
+ except httpx.HTTPError as e:
return f"Error: Failed to fetch states. {e}"
except (ValueError, TypeError) as e:
return f"Error processing response: {e}"
diff --git a/src/lfx/src/lfx/components/huggingface/huggingface_inference_api.py b/src/lfx/src/lfx/components/huggingface/huggingface_inference_api.py
index 6f58aad2852a..405973fc076e 100644
--- a/src/lfx/src/lfx/components/huggingface/huggingface_inference_api.py
+++ b/src/lfx/src/lfx/components/huggingface/huggingface_inference_api.py
@@ -1,6 +1,6 @@
from urllib.parse import urlparse
-import requests
+import httpx
from langchain_community.embeddings.huggingface import HuggingFaceInferenceAPIEmbeddings
# Next update: use langchain_huggingface
@@ -10,6 +10,7 @@
from lfx.base.embeddings.model import LCEmbeddingsModel
from lfx.field_typing import Embeddings
from lfx.io import MessageTextInput, Output, SecretStrInput
+from lfx.utils.ssrf_httpx import ssrf_safe_httpx_get, validate_url_for_ssrf_or_raise
class HuggingFaceInferenceAPIEmbeddingsComponent(LCEmbeddingsModel):
@@ -57,15 +58,15 @@ def validate_inference_endpoint(self, inference_endpoint: str) -> bool:
raise ValueError(msg)
try:
- response = requests.get(f"{inference_endpoint}/health", timeout=5)
- except requests.RequestException as e:
+ response = ssrf_safe_httpx_get(f"{inference_endpoint}/health", timeout=5)
+ except httpx.HTTPError as e:
msg = (
f"Inference endpoint '{inference_endpoint}' is not responding. "
"Please ensure the URL is correct and the service is running."
)
raise ValueError(msg) from e
- if response.status_code != requests.codes.ok:
+ if response.status_code != httpx.codes.OK:
msg = f"Hugging Face health check failed: {response.status_code}"
raise ValueError(msg)
# returning True to solve linting error
@@ -84,6 +85,7 @@ def create_huggingface_embeddings(
def build_embeddings(self) -> Embeddings:
api_url = self.get_api_url()
+ validate_url_for_ssrf_or_raise(api_url)
is_local_url = (
api_url.startswith(("http://localhost", "http://127.0.0.1", "http://0.0.0.0", "http://docker"))
diff --git a/src/lfx/src/lfx/components/litellm/litellm_proxy.py b/src/lfx/src/lfx/components/litellm/litellm_proxy.py
index b2ed501cb11d..d6bc503177a0 100644
--- a/src/lfx/src/lfx/components/litellm/litellm_proxy.py
+++ b/src/lfx/src/lfx/components/litellm/litellm_proxy.py
@@ -6,6 +6,8 @@
from lfx.field_typing.range_spec import RangeSpec
from lfx.inputs.inputs import IntInput, SecretStrInput, SliderInput, StrInput
from lfx.utils.secrets import secret_value_to_str
+from lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_httpx_get
+from lfx.utils.ssrf_protection import SSRFProtectionError
class LiteLLMProxyComponent(LCModelComponent):
@@ -72,6 +74,7 @@ class LiteLLMProxyComponent(LCModelComponent):
def build_model(self) -> LanguageModel:
"""Build the LiteLLM proxy model."""
api_key = secret_value_to_str(self.api_key) or ""
+ ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(self.api_base)
self._validate_proxy_connection(api_key)
@@ -84,6 +87,7 @@ def build_model(self) -> LanguageModel:
timeout=self.timeout,
max_retries=self.max_retries,
streaming=self.stream,
+ **ssrf_client_kwargs,
)
def _validate_proxy_connection(self, api_key: str) -> None:
@@ -92,11 +96,14 @@ def _validate_proxy_connection(self, api_key: str) -> None:
models_url = f"{base_url}/models"
try:
- response = httpx.get(
+ response = ssrf_safe_httpx_get(
models_url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
+ except SSRFProtectionError as e:
+ msg = f"SSRF Protection: {e}"
+ raise ValueError(msg) from e
except httpx.ConnectError as e:
msg = (
f"Could not connect to LiteLLM Proxy at {base_url}. Verify the URL is correct and the proxy is running."
diff --git a/src/lfx/src/lfx/components/lmstudio/lmstudioembeddings.py b/src/lfx/src/lfx/components/lmstudio/lmstudioembeddings.py
index 23d6e2339d04..0d461328bc72 100644
--- a/src/lfx/src/lfx/components/lmstudio/lmstudioembeddings.py
+++ b/src/lfx/src/lfx/components/lmstudio/lmstudioembeddings.py
@@ -1,12 +1,12 @@
from typing import Any
from urllib.parse import urljoin
-import httpx
-
from lfx.base.embeddings.model import LCEmbeddingsModel
from lfx.field_typing import Embeddings
from lfx.inputs.inputs import DropdownInput, SecretStrInput
from lfx.io import FloatInput, MessageTextInput
+from lfx.utils.ssrf_httpx import ssrf_safe_async_get, validate_url_for_ssrf_or_raise
+from lfx.utils.ssrf_protection import SSRFProtectionError
class LMStudioEmbeddingsComponent(LCEmbeddingsModel):
@@ -31,12 +31,14 @@ async def update_build_config(self, build_config: dict, field_value: Any, field_
async def get_model(base_url_value: str) -> list[str]:
try:
url = urljoin(base_url_value, "/v1/models")
- async with httpx.AsyncClient() as client:
- response = await client.get(url)
- response.raise_for_status()
- data = response.json()
+ response = await ssrf_safe_async_get(url)
+ response.raise_for_status()
+ data = response.json()
- return [model["id"] for model in data.get("data", [])]
+ return [model["id"] for model in data.get("data", [])]
+ except SSRFProtectionError as e:
+ msg = f"SSRF Protection: {e}"
+ raise ValueError(msg) from e
except Exception as e:
msg = "Could not retrieve models. Please, make sure the LM Studio server is running."
raise ValueError(msg) from e
@@ -71,6 +73,8 @@ async def get_model(base_url_value: str) -> list[str]:
]
def build_embeddings(self) -> Embeddings:
+ validate_url_for_ssrf_or_raise(self.base_url)
+
try:
from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings
except ImportError as e:
diff --git a/src/lfx/src/lfx/components/lmstudio/lmstudiomodel.py b/src/lfx/src/lfx/components/lmstudio/lmstudiomodel.py
index c8fa55ab99db..b1e6d12f7174 100644
--- a/src/lfx/src/lfx/components/lmstudio/lmstudiomodel.py
+++ b/src/lfx/src/lfx/components/lmstudio/lmstudiomodel.py
@@ -8,6 +8,8 @@
from lfx.field_typing import LanguageModel
from lfx.field_typing.range_spec import RangeSpec
from lfx.inputs.inputs import DictInput, DropdownInput, FloatInput, IntInput, SecretStrInput, StrInput
+from lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_async_get
+from lfx.utils.ssrf_protection import SSRFProtectionError
class LMStudioModelComponent(LCModelComponent):
@@ -24,9 +26,11 @@ async def update_build_config(self, build_config: dict, field_value: Any, field_
if base_url_load_from_db:
base_url_value = await self.get_variables(base_url_value, field_name)
try:
- async with httpx.AsyncClient() as client:
- response = await client.get(urljoin(base_url_value, "/v1/models"), timeout=2.0)
- response.raise_for_status()
+ response = await ssrf_safe_async_get(urljoin(base_url_value, "/v1/models"), timeout=2.0)
+ response.raise_for_status()
+ except SSRFProtectionError as e:
+ msg = f"SSRF Protection: {e}"
+ raise ValueError(msg) from e
except httpx.HTTPError:
msg = "Could not access the default LM Studio URL. Please, specify the 'Base URL' field."
self.log(msg)
@@ -39,12 +43,14 @@ async def update_build_config(self, build_config: dict, field_value: Any, field_
async def get_model(base_url_value: str) -> list[str]:
try:
url = urljoin(base_url_value, "/v1/models")
- async with httpx.AsyncClient() as client:
- response = await client.get(url)
- response.raise_for_status()
- data = response.json()
+ response = await ssrf_safe_async_get(url)
+ response.raise_for_status()
+ data = response.json()
- return [model["id"] for model in data.get("data", [])]
+ return [model["id"] for model in data.get("data", [])]
+ except SSRFProtectionError as e:
+ msg = f"SSRF Protection: {e}"
+ raise ValueError(msg) from e
except Exception as e:
msg = "Could not retrieve models. Please, make sure the LM Studio server is running."
raise ValueError(msg) from e
@@ -103,6 +109,8 @@ def build_model(self) -> LanguageModel: # type: ignore[type-var]
base_url = self.base_url or "http://localhost:1234/v1"
seed = self.seed
+ ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(base_url)
+
return ChatOpenAI(
max_tokens=max_tokens or None,
model_kwargs=model_kwargs,
@@ -111,6 +119,7 @@ def build_model(self) -> LanguageModel: # type: ignore[type-var]
api_key=lmstudio_api_key,
temperature=temperature if temperature is not None else 0.1,
seed=seed,
+ **ssrf_client_kwargs,
)
def _get_exception_message(self, e: Exception):
diff --git a/src/lfx/src/lfx/components/ollama/ollama.py b/src/lfx/src/lfx/components/ollama/ollama.py
index 70d5a54d848f..5d14b6e364b5 100644
--- a/src/lfx/src/lfx/components/ollama/ollama.py
+++ b/src/lfx/src/lfx/components/ollama/ollama.py
@@ -28,6 +28,12 @@
from lfx.schema.data import Data
from lfx.schema.dataframe import DataFrame
from lfx.schema.table import EditMode
+from lfx.utils.ssrf_httpx import (
+ ssrf_protected_httpx_client_kwargs_for_url,
+ ssrf_safe_async_get,
+ ssrf_safe_async_post,
+)
+from lfx.utils.ssrf_protection import SSRFProtectionError
from lfx.utils.util import transform_localhost_url
HTTP_STATUS_OK = 200
@@ -263,6 +269,8 @@ def build_model(self) -> LanguageModel: # type: ignore[type-var]
"Learn more at https://docs.ollama.com/openai#openai-compatibility"
)
+ sync_client_kwargs, async_client_kwargs = ssrf_protected_httpx_client_kwargs_for_url(transformed_base_url)
+
try:
output_format = self._parse_format_field(self.format) if self.enable_structured_output else None
except Exception as e:
@@ -297,6 +305,10 @@ def build_model(self) -> LanguageModel: # type: ignore[type-var]
headers = self.headers
if headers is not None:
llm_params["client_kwargs"] = {"headers": headers}
+ if sync_client_kwargs:
+ llm_params["sync_client_kwargs"] = sync_client_kwargs
+ if async_client_kwargs:
+ llm_params["async_client_kwargs"] = async_client_kwargs
# Remove parameters with None values
llm_params = {k: v for k, v in llm_params.items() if v is not None}
@@ -314,19 +326,21 @@ def build_model(self) -> LanguageModel: # type: ignore[type-var]
async def is_valid_ollama_url(self, url: str) -> bool:
try:
- async with httpx.AsyncClient() as client:
- url = transform_localhost_url(url)
- if not url:
- return False
- # Strip /v1 suffix if present, as Ollama API endpoints are at root level
- url = url.rstrip("/").removesuffix("/v1")
- if not url.endswith("/"):
- url = url + "/"
- return (
- await client.get(url=urljoin(url, "api/tags"), headers=self.headers)
- ).status_code == HTTP_STATUS_OK
+ url = transform_localhost_url(url)
+ if not url:
+ return False
+ # Strip /v1 suffix if present, as Ollama API endpoints are at root level
+ url = url.rstrip("/").removesuffix("/v1")
+ if not url.endswith("/"):
+ url = url + "/"
+ response = await ssrf_safe_async_get(urljoin(url, "api/tags"), headers=self.headers)
+ except SSRFProtectionError as e:
+ msg = f"SSRF Protection: {e}"
+ raise ValueError(msg) from e
except httpx.RequestError:
return False
+ else:
+ return response.status_code == HTTP_STATUS_OK
async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):
if field_name == "enable_structured_output": # bind enable_structured_output boolean to format show value
@@ -409,44 +423,46 @@ async def get_models(self, base_url_value: str, *, tool_model_enabled: bool | No
# Ollama REST API to return model capabilities
show_url = urljoin(base_url, "api/show")
- async with httpx.AsyncClient() as client:
- headers = self.headers
- # Fetch available models
- tags_response = await client.get(url=tags_url, headers=headers)
- tags_response.raise_for_status()
- models = tags_response.json()
- if asyncio.iscoroutine(models):
- models = await models
- await logger.adebug(f"Available models: {models}")
-
- # Filter models that are NOT embedding models
- model_ids = []
- for model in models[self.JSON_MODELS_KEY]:
- model_name = model[self.JSON_NAME_KEY]
- await logger.adebug(f"Checking model: {model_name}")
-
- payload = {"model": model_name}
- show_response = await client.post(url=show_url, json=payload, headers=headers)
- show_response.raise_for_status()
- json_data = show_response.json()
- if asyncio.iscoroutine(json_data):
- json_data = await json_data
-
- capabilities = json_data.get(self.JSON_CAPABILITIES_KEY)
- await logger.adebug(f"Model: {model_name}, Capabilities: {capabilities}")
-
- # If capabilities not provided, assume it's a completion model (backwards compatibility
- # with older Ollama versions that don't return capabilities from /api/show)
- if capabilities is None:
- if not tool_model_enabled:
- model_ids.append(model_name)
- # If tool_model_enabled is True but no capabilities info, skip the model
- # since we can't verify tool support
- elif self.DESIRED_CAPABILITY in capabilities and (
- not tool_model_enabled or self.TOOL_CALLING_CAPABILITY in capabilities
- ):
+ headers = self.headers
+ # Fetch available models
+ tags_response = await ssrf_safe_async_get(tags_url, headers=headers)
+ tags_response.raise_for_status()
+ models = tags_response.json()
+ if asyncio.iscoroutine(models):
+ models = await models
+ await logger.adebug(f"Available models: {models}")
+
+ # Filter models that are NOT embedding models
+ model_ids = []
+ for model in models[self.JSON_MODELS_KEY]:
+ model_name = model[self.JSON_NAME_KEY]
+ await logger.adebug(f"Checking model: {model_name}")
+
+ payload = {"model": model_name}
+ show_response = await ssrf_safe_async_post(show_url, json=payload, headers=headers)
+ show_response.raise_for_status()
+ json_data = show_response.json()
+ if asyncio.iscoroutine(json_data):
+ json_data = await json_data
+
+ capabilities = json_data.get(self.JSON_CAPABILITIES_KEY)
+ await logger.adebug(f"Model: {model_name}, Capabilities: {capabilities}")
+
+ # If capabilities not provided, assume it's a completion model (backwards compatibility
+ # with older Ollama versions that don't return capabilities from /api/show)
+ if capabilities is None:
+ if not tool_model_enabled:
model_ids.append(model_name)
-
+ # If tool_model_enabled is True but no capabilities info, skip the model
+ # since we can't verify tool support
+ elif self.DESIRED_CAPABILITY in capabilities and (
+ not tool_model_enabled or self.TOOL_CALLING_CAPABILITY in capabilities
+ ):
+ model_ids.append(model_name)
+
+ except SSRFProtectionError as e:
+ msg = f"SSRF Protection: {e}"
+ raise ValueError(msg) from e
except (httpx.RequestError, ValueError) as e:
msg = "Could not get model names from Ollama."
raise ValueError(msg) from e
diff --git a/src/lfx/src/lfx/components/ollama/ollama_embeddings.py b/src/lfx/src/lfx/components/ollama/ollama_embeddings.py
index 6f6655cc66a0..93fd5e2594d1 100644
--- a/src/lfx/src/lfx/components/ollama/ollama_embeddings.py
+++ b/src/lfx/src/lfx/components/ollama/ollama_embeddings.py
@@ -9,6 +9,12 @@
from lfx.field_typing import Embeddings
from lfx.io import DropdownInput, Output, SecretStrInput, StrInput
from lfx.log.logger import logger
+from lfx.utils.ssrf_httpx import (
+ ssrf_protected_httpx_client_kwargs_for_url,
+ ssrf_safe_async_get,
+ ssrf_safe_async_post,
+)
+from lfx.utils.ssrf_protection import SSRFProtectionError
from lfx.utils.util import transform_localhost_url
HTTP_STATUS_OK = 200
@@ -81,11 +87,17 @@ def build_embeddings(self) -> Embeddings:
"Learn more at https://docs.ollama.com/openai#openai-compatibility"
)
+ sync_client_kwargs, async_client_kwargs = ssrf_protected_httpx_client_kwargs_for_url(transformed_base_url)
+
llm_params = {
"model": self.model_name,
"base_url": transformed_base_url,
}
+ if sync_client_kwargs:
+ llm_params["sync_client_kwargs"] = sync_client_kwargs
+ if async_client_kwargs:
+ llm_params["async_client_kwargs"] = async_client_kwargs
if self.headers:
llm_params["client_kwargs"] = {"headers": self.headers}
@@ -133,35 +145,37 @@ async def get_model(self, base_url_value: str) -> list[str]:
# Ollama REST API to return model capabilities
show_url = urljoin(base_url, "api/show")
- async with httpx.AsyncClient() as client:
- headers = self.headers
- # Fetch available models
- tags_response = await client.get(url=tags_url, headers=headers)
- tags_response.raise_for_status()
- models = tags_response.json()
- if asyncio.iscoroutine(models):
- models = await models
- await logger.adebug(f"Available models: {models}")
-
- # Filter models that are embedding models
- model_ids = []
- for model in models[self.JSON_MODELS_KEY]:
- model_name = model[self.JSON_NAME_KEY]
- await logger.adebug(f"Checking model: {model_name}")
-
- payload = {"model": model_name}
- show_response = await client.post(url=show_url, json=payload, headers=headers)
- show_response.raise_for_status()
- json_data = show_response.json()
- if asyncio.iscoroutine(json_data):
- json_data = await json_data
-
- capabilities = json_data.get(self.JSON_CAPABILITIES_KEY, [])
- await logger.adebug(f"Model: {model_name}, Capabilities: {capabilities}")
-
- if self.EMBEDDING_CAPABILITY in capabilities:
- model_ids.append(model_name)
-
+ headers = self.headers
+ # Fetch available models
+ tags_response = await ssrf_safe_async_get(tags_url, headers=headers)
+ tags_response.raise_for_status()
+ models = tags_response.json()
+ if asyncio.iscoroutine(models):
+ models = await models
+ await logger.adebug(f"Available models: {models}")
+
+ # Filter models that are embedding models
+ model_ids = []
+ for model in models[self.JSON_MODELS_KEY]:
+ model_name = model[self.JSON_NAME_KEY]
+ await logger.adebug(f"Checking model: {model_name}")
+
+ payload = {"model": model_name}
+ show_response = await ssrf_safe_async_post(show_url, json=payload, headers=headers)
+ show_response.raise_for_status()
+ json_data = show_response.json()
+ if asyncio.iscoroutine(json_data):
+ json_data = await json_data
+
+ capabilities = json_data.get(self.JSON_CAPABILITIES_KEY, [])
+ await logger.adebug(f"Model: {model_name}, Capabilities: {capabilities}")
+
+ if self.EMBEDDING_CAPABILITY in capabilities:
+ model_ids.append(model_name)
+
+ except SSRFProtectionError as e:
+ msg = f"SSRF Protection: {e}"
+ raise ValueError(msg) from e
except (httpx.RequestError, ValueError) as e:
msg = "Could not get model names from Ollama."
raise ValueError(msg) from e
@@ -170,16 +184,18 @@ async def get_model(self, base_url_value: str) -> list[str]:
async def is_valid_ollama_url(self, url: str) -> bool:
try:
- async with httpx.AsyncClient() as client:
- url = transform_localhost_url(url)
- if not url:
- return False
- # Strip /v1 suffix if present, as Ollama API endpoints are at root level
- url = url.rstrip("/").removesuffix("/v1")
- if not url.endswith("/"):
- url = url + "/"
- return (
- await client.get(url=urljoin(url, "api/tags"), headers=self.headers)
- ).status_code == HTTP_STATUS_OK
+ url = transform_localhost_url(url)
+ if not url:
+ return False
+ # Strip /v1 suffix if present, as Ollama API endpoints are at root level
+ url = url.rstrip("/").removesuffix("/v1")
+ if not url.endswith("/"):
+ url = url + "/"
+ response = await ssrf_safe_async_get(urljoin(url, "api/tags"), headers=self.headers)
+ except SSRFProtectionError as e:
+ msg = f"SSRF Protection: {e}"
+ raise ValueError(msg) from e
except httpx.RequestError:
return False
+ else:
+ return response.status_code == HTTP_STATUS_OK
diff --git a/src/lfx/src/lfx/components/xai/xai.py b/src/lfx/src/lfx/components/xai/xai.py
index e5816e6ca305..df791e547ba6 100644
--- a/src/lfx/src/lfx/components/xai/xai.py
+++ b/src/lfx/src/lfx/components/xai/xai.py
@@ -1,4 +1,4 @@
-import requests
+import httpx
from langchain_openai import ChatOpenAI
from pydantic.v1 import SecretStr
from typing_extensions import override
@@ -15,6 +15,8 @@
SecretStrInput,
SliderInput,
)
+from lfx.utils.ssrf_httpx import ssrf_protected_openai_clients_for_url, ssrf_safe_httpx_get
+from lfx.utils.ssrf_protection import SSRFProtectionError
XAI_DEFAULT_MODELS = ["grok-2-latest"]
@@ -97,7 +99,7 @@ def get_models(self) -> list[str]:
headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json"}
try:
- response = requests.get(url, headers=headers, timeout=10)
+ response = ssrf_safe_httpx_get(url, headers=headers, timeout=10)
response.raise_for_status()
data = response.json()
@@ -108,7 +110,10 @@ def get_models(self) -> list[str]:
models.update(model.get("aliases", []))
return sorted(models) if models else XAI_DEFAULT_MODELS
- except requests.RequestException as e:
+ except SSRFProtectionError as e:
+ self.status = f"SSRF Protection: {e}"
+ return XAI_DEFAULT_MODELS
+ except httpx.HTTPError as e:
self.status = f"Error fetching models: {e}"
return XAI_DEFAULT_MODELS
@@ -130,6 +135,7 @@ def build_model(self) -> LanguageModel:
json_mode = self.json_mode
seed = self.seed
+ ssrf_client_kwargs = ssrf_protected_openai_clients_for_url(base_url)
api_key = SecretStr(api_key).get_secret_value() if api_key else None
output = ChatOpenAI(
@@ -140,6 +146,7 @@ def build_model(self) -> LanguageModel:
api_key=api_key,
temperature=temperature if temperature is not None else 0.1,
seed=seed,
+ **ssrf_client_kwargs,
)
if json_mode:
diff --git a/src/lfx/src/lfx/utils/ssrf_httpx.py b/src/lfx/src/lfx/utils/ssrf_httpx.py
new file mode 100644
index 000000000000..7ebf1bdbd836
--- /dev/null
+++ b/src/lfx/src/lfx/utils/ssrf_httpx.py
@@ -0,0 +1,118 @@
+"""SSRF-protected helpers for ``httpx`` call sites."""
+
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import urlparse
+
+import httpx
+
+from lfx.utils.ssrf_protection import (
+ SSRFProtectionError,
+ is_ssrf_protection_enabled,
+ validate_and_resolve_url,
+ validate_url_for_ssrf,
+)
+from lfx.utils.ssrf_transport import (
+ SSRFProtectedSyncTransport,
+ SSRFProtectedTransport,
+ create_ssrf_protected_client,
+ create_ssrf_protected_sync_client,
+)
+
+
+def validate_url_for_ssrf_or_raise(url: str) -> None:
+ """Validate a user URL and raise a UI-facing error when it is blocked."""
+ try:
+ validate_url_for_ssrf(url, warn_only=False)
+ except SSRFProtectionError as e:
+ msg = f"SSRF Protection: {e}"
+ raise ValueError(msg) from e
+
+
+def _raise_if_following_redirects(request_kwargs: dict[str, Any]) -> None:
+ if request_kwargs.get("follow_redirects"):
+ msg = "SSRF-protected httpx helpers do not support automatic redirect following."
+ raise SSRFProtectionError(msg)
+
+
+def _async_client_for_url(url: str, validated_ips: list[str]) -> httpx.AsyncClient:
+ if is_ssrf_protection_enabled() and validated_ips:
+ hostname = urlparse(url).hostname
+ if hostname:
+ return create_ssrf_protected_client(hostname=hostname, validated_ips=validated_ips)
+ return httpx.AsyncClient()
+
+
+def _sync_client_for_url(url: str, validated_ips: list[str]) -> httpx.Client:
+ if is_ssrf_protection_enabled() and validated_ips:
+ hostname = urlparse(url).hostname
+ if hostname:
+ return create_ssrf_protected_sync_client(hostname=hostname, validated_ips=validated_ips)
+ return httpx.Client()
+
+
+def ssrf_protected_httpx_client_kwargs_for_url(url: str) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Return sync/async httpx kwargs that enforce SSRF protection for SDK clients."""
+ try:
+ validated_url, validated_ips = validate_and_resolve_url(url)
+ except SSRFProtectionError as e:
+ msg = f"SSRF Protection: {e}"
+ raise ValueError(msg) from e
+
+ if not is_ssrf_protection_enabled():
+ return {}, {}
+
+ sync_kwargs: dict[str, Any] = {"follow_redirects": False}
+ async_kwargs: dict[str, Any] = {"follow_redirects": False}
+
+ hostname = urlparse(validated_url).hostname
+ if hostname and validated_ips:
+ ip_list = list(validated_ips)
+ sync_kwargs["transport"] = SSRFProtectedSyncTransport(pinned_ips={hostname: ip_list})
+ async_kwargs["transport"] = SSRFProtectedTransport(pinned_ips={hostname: ip_list})
+
+ return sync_kwargs, async_kwargs
+
+
+def ssrf_protected_openai_clients_for_url(url: str) -> dict[str, httpx.Client | httpx.AsyncClient]:
+ """Return pinned sync and async clients for OpenAI-compatible LangChain components."""
+ sync_kwargs, async_kwargs = ssrf_protected_httpx_client_kwargs_for_url(url)
+ if not sync_kwargs and not async_kwargs:
+ return {}
+ return {
+ "http_client": httpx.Client(**sync_kwargs),
+ "http_async_client": httpx.AsyncClient(**async_kwargs),
+ }
+
+
+async def ssrf_safe_async_get(url: str, **request_kwargs: Any) -> httpx.Response:
+ """Perform an async GET with SSRF validation and DNS pinning."""
+ _raise_if_following_redirects(request_kwargs)
+ validated_url, validated_ips = validate_and_resolve_url(url)
+ async with _async_client_for_url(validated_url, validated_ips) as client:
+ return await client.get(url=validated_url, **request_kwargs)
+
+
+async def ssrf_safe_async_post(url: str, **request_kwargs: Any) -> httpx.Response:
+ """Perform an async POST with SSRF validation and DNS pinning."""
+ _raise_if_following_redirects(request_kwargs)
+ validated_url, validated_ips = validate_and_resolve_url(url)
+ async with _async_client_for_url(validated_url, validated_ips) as client:
+ return await client.post(url=validated_url, **request_kwargs)
+
+
+def ssrf_safe_httpx_get(url: str, **request_kwargs: Any) -> httpx.Response:
+ """Perform a synchronous GET with SSRF validation and DNS pinning."""
+ _raise_if_following_redirects(request_kwargs)
+ validated_url, validated_ips = validate_and_resolve_url(url)
+ with _sync_client_for_url(validated_url, validated_ips) as client:
+ return client.get(url=validated_url, **request_kwargs)
+
+
+def ssrf_safe_httpx_post(url: str, **request_kwargs: Any) -> httpx.Response:
+ """Perform a synchronous POST with SSRF validation and DNS pinning."""
+ _raise_if_following_redirects(request_kwargs)
+ validated_url, validated_ips = validate_and_resolve_url(url)
+ with _sync_client_for_url(validated_url, validated_ips) as client:
+ return client.post(url=validated_url, **request_kwargs)
diff --git a/src/lfx/src/lfx/utils/ssrf_transport.py b/src/lfx/src/lfx/utils/ssrf_transport.py
index 86e6cdf20a9d..aa77af7189c8 100644
--- a/src/lfx/src/lfx/utils/ssrf_transport.py
+++ b/src/lfx/src/lfx/utils/ssrf_transport.py
@@ -135,6 +135,82 @@ async def sleep(self, seconds: float) -> None:
await self._backend.sleep(seconds)
+class DNSPinningSyncNetworkBackend(httpcore.NetworkBackend):
+ """Synchronous network backend that pins DNS resolution to validated IP addresses."""
+
+ def __init__(self, pinned_ips: dict[str, list[str]], backend: httpcore.NetworkBackend | None = None):
+ self.pinned_ips = pinned_ips
+ if backend is None:
+ backend = httpcore.SyncBackend()
+ self._backend = backend
+ logger.debug(f"Created sync DNS pinning network backend with pinned IPs: {pinned_ips}")
+
+ def connect_tcp(
+ self,
+ host: str,
+ port: int,
+ timeout: float | None = None,
+ local_address: str | None = None,
+ socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
+ ) -> httpcore.NetworkStream:
+ """Connect to TCP socket, using pinned IPs for hosts that were validated."""
+ if host in self.pinned_ips:
+ pinned_ips = self.pinned_ips[host]
+
+ if not pinned_ips:
+ msg = f"DNS pinning: Host {host} is marked for pinning but has no pinned IPs"
+ logger.error(msg)
+ raise RuntimeError(msg)
+
+ logger.debug(f"DNS pinning: Connecting to pinned IPs {pinned_ips} for hostname {host}")
+
+ last_error = None
+ for pinned_ip in pinned_ips:
+ try:
+ logger.debug(f"DNS pinning: Attempting connection to {pinned_ip}")
+ return self._backend.connect_tcp(
+ host=pinned_ip,
+ port=port,
+ timeout=timeout,
+ local_address=local_address,
+ socket_options=socket_options,
+ )
+ except (OSError, TimeoutError) as e:
+ last_error = e
+ logger.debug(f"DNS pinning: Failed to connect to {pinned_ip}: {e}")
+ continue
+
+ if last_error is None:
+ msg = f"DNS pinning: All pinned IPs failed for {host} but no error was captured"
+ raise RuntimeError(msg)
+ raise last_error
+
+ return self._backend.connect_tcp(
+ host=host,
+ port=port,
+ timeout=timeout,
+ local_address=local_address,
+ socket_options=socket_options,
+ )
+
+ def connect_unix_socket(
+ self,
+ path: str,
+ timeout: float | None = None,
+ socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
+ ) -> httpcore.NetworkStream:
+ """Connect to Unix socket (pass through to underlying backend)."""
+ return self._backend.connect_unix_socket(
+ path=path,
+ timeout=timeout,
+ socket_options=socket_options,
+ )
+
+ def sleep(self, seconds: float) -> None:
+ """Sleep for specified duration (pass through to underlying backend)."""
+ self._backend.sleep(seconds)
+
+
class SSRFProtectedTransport(httpx.AsyncHTTPTransport):
"""HTTP transport that pins DNS resolution to validated IPs.
@@ -222,6 +298,52 @@ def __init__(
logger.debug(f"Created SSRF protected transport with pinned IPs: {pinned_ips}")
+class SSRFProtectedSyncTransport(httpx.HTTPTransport):
+ """Synchronous HTTP transport that pins DNS resolution to validated IPs."""
+
+ def __init__(
+ self,
+ pinned_ips: dict[str, list[str]],
+ verify: bool | str | ssl.SSLContext = True, # noqa: FBT001, FBT002
+ cert: tuple[str, str] | tuple[str, str, str] | str | None = None,
+ trust_env: bool = True, # noqa: FBT001, FBT002
+ http1: bool = True, # noqa: FBT001, FBT002
+ http2: bool = False, # noqa: FBT001, FBT002
+ limits: httpx.Limits = httpx.Limits(), # noqa: B008
+ proxy: httpx._types.ProxyTypes | None = None,
+ uds: str | None = None,
+ local_address: str | None = None,
+ retries: int = 0,
+ socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
+ ):
+ network_backend = DNSPinningSyncNetworkBackend(pinned_ips=pinned_ips)
+ ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
+
+ if proxy is not None:
+ proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
+
+ if proxy is None:
+ self._pool = httpcore.ConnectionPool(
+ ssl_context=ssl_context,
+ max_connections=limits.max_connections,
+ max_keepalive_connections=limits.max_keepalive_connections,
+ keepalive_expiry=limits.keepalive_expiry,
+ http1=http1,
+ http2=http2,
+ uds=uds,
+ local_address=local_address,
+ retries=retries,
+ socket_options=socket_options,
+ network_backend=network_backend,
+ )
+ else:
+ msg = "DNS pinning with proxies is not currently supported"
+ raise NotImplementedError(msg)
+
+ self.pinned_ips = pinned_ips
+ logger.debug(f"Created synchronous SSRF protected transport with pinned IPs: {pinned_ips}")
+
+
def create_ssrf_protected_client(
hostname: str, validated_ips: list[str] | tuple[str, ...], **client_kwargs
) -> httpx.AsyncClient:
@@ -240,3 +362,12 @@ def create_ssrf_protected_client(
ip_list = list(validated_ips) if isinstance(validated_ips, tuple) else validated_ips
transport = SSRFProtectedTransport(pinned_ips={hostname: ip_list})
return httpx.AsyncClient(transport=transport, **client_kwargs)
+
+
+def create_ssrf_protected_sync_client(
+ hostname: str, validated_ips: list[str] | tuple[str, ...], **client_kwargs
+) -> httpx.Client:
+ """Create a synchronous httpx client with DNS pinning for SSRF protection."""
+ ip_list = list(validated_ips) if isinstance(validated_ips, tuple) else validated_ips
+ transport = SSRFProtectedSyncTransport(pinned_ips={hostname: ip_list})
+ return httpx.Client(transport=transport, **client_kwargs)
diff --git a/src/lfx/tests/unit/utils/test_ssrf_httpx.py b/src/lfx/tests/unit/utils/test_ssrf_httpx.py
new file mode 100644
index 000000000000..328c19d0df70
--- /dev/null
+++ b/src/lfx/tests/unit/utils/test_ssrf_httpx.py
@@ -0,0 +1,55 @@
+import os
+import socket
+from unittest.mock import patch
+
+import httpcore
+import pytest
+from lfx.utils.ssrf_httpx import ssrf_safe_httpx_get
+from lfx.utils.ssrf_protection import SSRFProtectionError
+
+
+class TestSSRFSafeHTTPX:
+ def test_direct_internal_ip_is_blocked(self):
+ with (
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ patch("httpx.Client.get") as mock_get,
+ pytest.raises(SSRFProtectionError),
+ ):
+ ssrf_safe_httpx_get("http://169.254.169.254/latest/meta-data/", timeout=5)
+ mock_get.assert_not_called()
+
+ def test_sync_dns_pinning_prevents_rebinding_attack(self):
+ call_count = 0
+ connected_to_ip = None
+
+ def mock_getaddrinfo(_hostname, _port, *_args, **_kwargs):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 0))]
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0))]
+
+ def mock_connect_tcp(_self, host, port, **_kwargs):
+ nonlocal connected_to_ip
+ assert port == 8080
+ connected_to_ip = host
+ return httpcore.MockStream(
+ [
+ b"HTTP/1.1 200 OK\r\n",
+ b"Content-Type: application/json\r\n",
+ b"Content-Length: 15\r\n",
+ b"\r\n",
+ b'{"status":"ok"}',
+ ]
+ )
+
+ with (
+ patch("socket.getaddrinfo", side_effect=mock_getaddrinfo),
+ patch.dict(os.environ, {"LANGFLOW_SSRF_PROTECTION_ENABLED": "true"}),
+ patch.object(httpcore.SyncBackend, "connect_tcp", mock_connect_tcp),
+ ):
+ response = ssrf_safe_httpx_get("http://rebinding.test:8080/models", timeout=5)
+
+ assert response.status_code == 200
+ assert call_count == 1
+ assert connected_to_ip == "8.8.8.8"