From 014f6d68a3fc8a32288e5ee19f0ff1a377013435 Mon Sep 17 00:00:00 2001 From: Thomas Menard Date: Sun, 3 May 2026 16:43:56 +0200 Subject: [PATCH] fix: connect fetch_tools to proxy server config parsing Adds parse_fetch_tools() method and integrates fetch_tools into: - ProxyConfig._load_router_config() - reads from YAML config - _update_llm_router() - hot-reload from database config fetch_tools can now be configured in proxy_config.yaml: fetch_tools: - fetch_tool_name: "my-firecrawl" litellm_params: fetch_provider: "firecrawl" api_key: "os.environ/FIRECRAWL_API_KEY" Also fixes mock in test_webfetch_interception.py (ui_friendly_name). --- litellm/proxy/proxy_server.py | 74 ++++++++++++++++++- .../test_webfetch_interception.py | 6 +- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 12229955299..bcf73d28eb7 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -560,6 +560,7 @@ from litellm.types.realtime import RealtimeQueryParams from litellm.types.router import DeploymentTypedDict from litellm.types.router import ModelInfo as RouterModelInfo from litellm.types.router import ( + FetchToolTypedDict, RouterGeneralSettings, SearchToolTypedDict, updateDeployment, @@ -3115,6 +3116,63 @@ class ProxyConfig: return search_tools_parsed if search_tools_parsed else None + def parse_fetch_tools(self, config: dict) -> Optional[List[FetchToolTypedDict]]: + """ + Parse and validate fetch tools from config. + Loads environment variables and casts to FetchToolTypedDict. + + Args: + config: Config dictionary containing fetch_tools + + Returns: + List of validated FetchToolTypedDict or None if not configured + """ + fetch_tools_raw = config.get("fetch_tools", None) + if not fetch_tools_raw: + # Check in general_settings + general_settings = config.get("general_settings", {}) + if general_settings: + fetch_tools_raw = general_settings.get("fetch_tools", None) + + if not fetch_tools_raw: + return None + + fetch_tools_parsed: List[FetchToolTypedDict] = [] + + print( # noqa + "\033[32mLiteLLM: Proxy initialized with Fetch Tools:\033[0m" + ) # noqa + + for fetch_tool in fetch_tools_raw: + # Display loaded fetch tool + fetch_tool_name = fetch_tool.get("fetch_tool_name", "") + fetch_provider = fetch_tool.get("litellm_params", {}).get( + "fetch_provider", "" + ) + print(f"\033[32m {fetch_tool_name} ({fetch_provider})\033[0m") # noqa + + # Handle os.environ/ variables in litellm_params + litellm_params = fetch_tool.get("litellm_params", {}) + if litellm_params: + for k, v in litellm_params.items(): + if isinstance(v, str) and v.startswith("os.environ/"): + _v = v.replace("os.environ/", "") + v = get_secret(_v) + litellm_params[k] = v + fetch_tool["litellm_params"] = litellm_params + + # Cast to FetchToolTypedDict for type safety + try: + fetch_tool_typed: FetchToolTypedDict = FetchToolTypedDict(**fetch_tool) # type: ignore + fetch_tools_parsed.append(fetch_tool_typed) + except Exception as e: + verbose_proxy_logger.error( + f"Error parsing fetch tool {fetch_tool_name}: {str(e)}" + ) + continue + + return fetch_tools_parsed if fetch_tools_parsed else None + # Environment variable keys that must not be overridden via config because # they can alter process execution, library loading, or network routing. _BLOCKED_ENV_KEYS: Set[str] = { @@ -3768,6 +3826,11 @@ class ProxyConfig: config ) + ## FETCH TOOLS SETTINGS + fetch_tools: Optional[List[FetchToolTypedDict]] = self.parse_fetch_tools( + config + ) + ## /fine_tuning/jobs endpoints config finetuning_config = config.get("finetune_settings", None) set_fine_tuning_config(config=finetuning_config) @@ -3786,10 +3849,11 @@ class ProxyConfig: router_settings = config.get("router_settings", None) if router_settings and isinstance(router_settings, dict): - # model list and search_tools already set + # model list, search_tools and fetch_tools already set exclude_args = { "model_list", "search_tools", + "fetch_tools", } available_args = [ @@ -3811,6 +3875,7 @@ class ProxyConfig: **router_params, assistants_config=assistants_config, search_tools=search_tools, + fetch_tools=fetch_tools, router_general_settings=RouterGeneralSettings( async_only_mode=True # only init async clients ), @@ -4240,9 +4305,11 @@ class ProxyConfig: # Load config separately so a timeout here doesn't block model loading config_data: dict = {} search_tools = None + fetch_tools = None try: config_data = await proxy_config.get_config() search_tools = self.parse_search_tools(config_data) + fetch_tools = self.parse_fetch_tools(config_data) except Exception as e: verbose_proxy_logger.warning( "Failed to load config in _update_llm_router: %s. " @@ -4260,7 +4327,7 @@ class ProxyConfig: ) # Only create router if we have models or search_tools to route # Router can function with model_list=[] if search_tools are configured - if len(_model_list) > 0 or search_tools: + if len(_model_list) > 0 or search_tools or fetch_tools: verbose_proxy_logger.debug(f"_model_list: {_model_list}") llm_router = litellm.Router( model_list=_model_list, @@ -4268,6 +4335,7 @@ class ProxyConfig: async_only_mode=True # only init async clients ), search_tools=search_tools, + fetch_tools=fetch_tools, ignore_invalid_deployments=True, ) verbose_proxy_logger.debug(f"updated llm_router: {llm_router}") @@ -4275,6 +4343,8 @@ class ProxyConfig: verbose_proxy_logger.debug(f"len new_models: {len(models_list)}") if search_tools is not None and llm_router is not None: llm_router.search_tools = search_tools + if fetch_tools is not None and llm_router is not None: + llm_router.fetch_tools = fetch_tools ## DELETE MODEL LOGIC await self._delete_deployment(db_models=models_list) diff --git a/tests/webfetch_tests/test_webfetch_interception.py b/tests/webfetch_tests/test_webfetch_interception.py index 96fad36a530..600331f7e5a 100644 --- a/tests/webfetch_tests/test_webfetch_interception.py +++ b/tests/webfetch_tests/test_webfetch_interception.py @@ -192,9 +192,10 @@ class TestWebFetchInterceptionLogger: with patch.object( logger, "_get_fetch_config", new_callable=AsyncMock ) as mock_get_config: + from unittest.mock import MagicMock mock_config = AsyncMock() mock_config.afetch_url = AsyncMock(return_value=mock_fetch_response) - mock_config.ui_friendly_name.return_value = "Firecrawl" + mock_config.ui_friendly_name = MagicMock(return_value="Firecrawl") mock_get_config.return_value = mock_config with patch( @@ -244,9 +245,10 @@ class TestWebFetchInterceptionLogger: with patch.object( logger, "_get_fetch_config", new_callable=AsyncMock ) as mock_get_config: + from unittest.mock import MagicMock mock_config = AsyncMock() mock_config.afetch_url = AsyncMock(return_value=mock_fetch_response) - mock_config.ui_friendly_name.return_value = "Firecrawl" + mock_config.ui_friendly_name = MagicMock(return_value="Firecrawl") mock_get_config.return_value = mock_config with patch(