From eab34a3995ab59c42449e34727d6b4beb0f9910a Mon Sep 17 00:00:00 2001 From: Brian Sparker Date: Fri, 5 Jun 2026 03:57:49 -0700 Subject: [PATCH] feat(search): add you_com as a search provider (#28370) * feat(search): add you_com as a search provider Registers You.com Search API as a first-class `search_provider` in the `search_tools` registry, alongside Tavily, Exa, Perplexity, etc. - New adapter: litellm/llms/you_com/search/transformation.py - POSTs to https://ydc-index.io/v1/search - Auth: X-API-Key from YOUCOM_API_KEY (or explicit api_key) - Maps Perplexity unified spec: max_results -> count, search_domain_filter -> include_domains, country -> country - Flattens results.web + results.news into a single SearchResult list; snippet prefers snippets[0], falls back to description; page_age -> date - Registry: SearchProviders.YOU_COM in litellm/types/utils.py and wired into ProviderConfigManager.get_provider_search_config() - Pricing entry: model_prices_and_context_window.json (placeholder $0.0; happy to adjust to maintainers' preferred public number) - Docs: example router config snippet and example proxy yaml updated - Tests: tests/search_tests/test_you_com_search.py - 5 mocked tests (payload shape, domain filter mapping, snippet fallback, news flattening, missing-api-key error) Refs upstream expansion signal: #15942 * review fixups: normalize api_base, lowercase country, scope env-var to test Addresses Greptile inline review comments on #28370: - get_complete_url: strip trailing slashes from api_base *before* the endswith("/v1/search") check, so a custom base like ".../v1/search/" doesn't become ".../v1/search/v1/search". - transform_search_request: .lower() country before sending, matching Tavily's convention so callers using the unified spec form ("US") get consistent behavior across providers. - Tests: replace direct os.environ writes with an autouse monkeypatch fixture so YOUCOM_API_KEY is set per-test and removed afterwards. The missing-key test now uses monkeypatch.delenv. New test asserts the trailing-slash normalization above. Reverts the ARCHITECTURE.md / example yaml edits per the reviewer note that documentation changes belong in the litellm-docs repo. * support keyless free tier (api.you.com/v1/agents/search) as default You.com offers an IP-throttled keyless endpoint that returns the same response shape as the keyed one (~100 queries/day, no signup). This is a significant onboarding lever - mirrors the keyless DuckDuckGo/SearXNG providers already in the search_tools registry. Behavior: - YOUCOM_API_KEY set -> keyed: POST https://ydc-index.io/v1/search (X-API-Key header) - no key -> free: POST https://api.you.com/v1/agents/search (no auth) - YOUCOM_API_BASE override -> honored as-is Tests: - New: test_you_com_search_keyless_free_tier - asserts URL + absence of X-API-Key when no key is configured. - New: test_you_com_search_validate_environment_keyless - asserts the config no longer raises when the key is absent. - Removed: test_you_com_search_raises_without_api_key (the precondition no longer holds). - Existing payload/domain-filter/etc tests still cover keyed mode via the autouse YOUCOM_API_KEY fixture. Verified both endpoints accept POST + return identical JSON shape: results.web[] / results.news[] with title, url, snippets, description, page_age. * register you_com in provider_endpoints_support.json Adding `litellm/llms/you_com/` requires a corresponding entry in provider_endpoints_support.json or the code-quality/check_provider_folders_documented CI check fails. Follows the compact tavily/serper pattern - endpoints: { search: true }. Local run of the check now reports "All 114 provider folders are documented". * move tests under tests/test_litellm/llms/ so CI exercises them The litellm CI workflows scope unit tests to `tests/test_litellm/...` (see test-unit-llm-providers.yml: `tests/test_litellm/llms` path), so tests living under `tests/search_tests/` are never run in CI - which is why codecov reports 0% patch coverage for the new adapter even though the unit tests exist and pass locally. Move test_you_com_search.py into `tests/test_litellm/llms/you_com/` so the test-unit-llm-providers job picks it up. 7/7 tests still pass at the new location. (Sibling search-only providers - tavily, exa_ai, brave, etc. - still live only in `tests/search_tests/` and would benefit from the same move, but that is out of scope for this PR.) * fix(you_com): pin Accept-Encoding: identity to dodge keyless gzip bug The keyless free-tier endpoint (api.you.com/v1/agents/search) advertises Content-Encoding: gzip but returns a body that httpx's decoder rejects with `zlib.error: Error -3 while decompressing data: incorrect header check`, surfacing as litellm.APIConnectionError in user code. curl works because it doesn't request compression by default. Pin Accept-Encoding: identity in validate_environment so the upstream server skips compression entirely. Harmless on the keyed endpoint (ydc-index.io/v1/search) which negotiates content-encoding correctly. The header uses setdefault so a caller-supplied Accept-Encoding still takes precedence. (Server-side bug has been flagged to the You.com team separately - once fixed there, this workaround can be removed.) New unit test: test_you_com_search_pins_identity_accept_encoding. --------- Co-authored-by: Sameer Kankute --- .../websearch_interception/ARCHITECTURE.md | 3 + litellm/llms/you_com/__init__.py | 0 litellm/llms/you_com/search/__init__.py | 7 + litellm/llms/you_com/search/transformation.py | 193 ++++++++++ .../websearch_interception_config.yaml | 4 + litellm/types/utils.py | 1 + litellm/utils.py | 2 + model_prices_and_context_window.json | 5 + provider_endpoints_support.json | 4 + tests/test_litellm/llms/you_com/__init__.py | 0 .../llms/you_com/test_you_com_search.py | 330 ++++++++++++++++++ 11 files changed, 549 insertions(+) create mode 100644 litellm/llms/you_com/__init__.py create mode 100644 litellm/llms/you_com/search/__init__.py create mode 100644 litellm/llms/you_com/search/transformation.py create mode 100644 tests/test_litellm/llms/you_com/__init__.py create mode 100644 tests/test_litellm/llms/you_com/test_you_com_search.py diff --git a/litellm/integrations/websearch_interception/ARCHITECTURE.md b/litellm/integrations/websearch_interception/ARCHITECTURE.md index 3aa0a1558d7..ce7f01c5a2a 100644 --- a/litellm/integrations/websearch_interception/ARCHITECTURE.md +++ b/litellm/integrations/websearch_interception/ARCHITECTURE.md @@ -244,6 +244,9 @@ search_tools: - search_tool_name: "my-tavily-tool" litellm_params: search_provider: "tavily" + - search_tool_name: "my-you-com-tool" + litellm_params: + search_provider: "you_com" ``` --- diff --git a/litellm/llms/you_com/__init__.py b/litellm/llms/you_com/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/you_com/search/__init__.py b/litellm/llms/you_com/search/__init__.py new file mode 100644 index 00000000000..41bd9ce6b1a --- /dev/null +++ b/litellm/llms/you_com/search/__init__.py @@ -0,0 +1,7 @@ +""" +You.com Search API module. +""" + +from litellm.llms.you_com.search.transformation import YouComSearchConfig + +__all__ = ["YouComSearchConfig"] diff --git a/litellm/llms/you_com/search/transformation.py b/litellm/llms/you_com/search/transformation.py new file mode 100644 index 00000000000..9a2144b2cb0 --- /dev/null +++ b/litellm/llms/you_com/search/transformation.py @@ -0,0 +1,193 @@ +""" +Calls You.com's /v1/search endpoint to search the web. + +You.com API Reference: https://you.com/docs/api-reference/search/v1-search +OpenAPI spec: https://you.com/specs/openapi_search_v1.yaml +""" + +from typing import Dict, List, Optional, TypedDict, Union + +import httpx + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + + +class _YouComSearchRequestRequired(TypedDict): + """Required fields for You.com Search API request.""" + + query: str + + +class YouComSearchRequest(_YouComSearchRequestRequired, total=False): + """ + You.com Search API request format. + Based on: https://you.com/specs/openapi_search_v1.yaml + """ + + count: int + country: str + language: str + freshness: str + include_domains: List[str] + exclude_domains: List[str] + safesearch: str + + +class YouComSearchConfig(BaseSearchConfig): + # Keyed tier (higher rate limits): authenticate with X-API-Key. + YOU_COM_API_BASE = "https://ydc-index.io" + # Keyless free tier: IP-throttled (100 queries/day) and requires no auth. + # Used automatically when YOUCOM_API_KEY is not set. + YOU_COM_FREE_API_BASE = "https://api.you.com/v1/agents/search" + + @staticmethod + def ui_friendly_name() -> str: + return "You.com" + + def validate_environment( + self, + headers: Dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, + ) -> Dict: + """ + Set headers for the You.com Search API. + + If YOUCOM_API_KEY (or an explicit api_key) is present, use the keyed + endpoint with the `X-API-Key` header. Otherwise fall through to the + keyless free tier; no auth header is required. + """ + api_key = api_key or get_secret_str("YOUCOM_API_KEY") + headers["Content-Type"] = "application/json" + # Pin Accept-Encoding to identity: the keyless `api.you.com/v1/agents/search` + # endpoint advertises gzip content-encoding but returns body bytes the + # decoder rejects, which surfaces as httpx.DecodingError through litellm's + # http handler. Identity is harmless on the keyed endpoint. + headers.setdefault("Accept-Encoding", "identity") + if api_key: + headers["X-API-Key"] = api_key + return headers + + def get_complete_url( + self, + api_base: Optional[str], + optional_params: dict, + data: Optional[Union[Dict, List[Dict]]] = None, + **kwargs, + ) -> str: + """ + Pick the endpoint based on whether an API key is configured. + + - api_base explicit override -> use it as-is (normalized) + - YOUCOM_API_KEY set -> keyed endpoint (ydc-index.io/v1/search) + - no key -> keyless free tier (api.you.com/v1/agents/search) + """ + if api_base is None: + api_base = get_secret_str("YOUCOM_API_BASE") + + if api_base is None: + api_key = get_secret_str("YOUCOM_API_KEY") + if api_key: + api_base = self.YOU_COM_API_BASE + else: + # Keyless free tier already includes the full path. + return self.YOU_COM_FREE_API_BASE + + api_base = api_base.rstrip("/") + + if not api_base.endswith("/v1/search") and not api_base.endswith( + "/v1/agents/search" + ): + api_base = f"{api_base}/v1/search" + + return api_base + + def transform_search_request( + self, + query: Union[str, List[str]], + optional_params: dict, + **kwargs, + ) -> Dict: + """ + Transform Search request to You.com API format. + + Perplexity unified spec → You.com mappings: + - query → query + - max_results → count + - search_domain_filter → include_domains + - country → country + - max_tokens_per_page → (not applicable, ignored) + """ + if isinstance(query, list): + query = " ".join(query) + + request_data: YouComSearchRequest = { + "query": query, + } + + if "max_results" in optional_params: + request_data["count"] = optional_params["max_results"] + + if "search_domain_filter" in optional_params: + request_data["include_domains"] = optional_params["search_domain_filter"] + + if "country" in optional_params: + request_data["country"] = optional_params["country"].lower() + + result_data = dict(request_data) + + for param, value in optional_params.items(): + if ( + param not in self.get_supported_perplexity_optional_params() + and param not in result_data + ): + result_data[param] = value + + return result_data + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs, + ) -> SearchResponse: + """ + Transform You.com API response to LiteLLM unified SearchResponse format. + + You.com → LiteLLM mappings (for both `results.web[]` and `results.news[]`): + - title → SearchResult.title + - url → SearchResult.url + - snippets[0] → SearchResult.snippet (falls back to `description`) + - page_age → SearchResult.date + """ + response_json = raw_response.json() + raw_results = response_json.get("results") or {} + + web_results = raw_results.get("web") or [] + news_results = raw_results.get("news") or [] + + results: List[SearchResult] = [] + for item in list(web_results) + list(news_results): + snippets = item.get("snippets") or [] + snippet = snippets[0] if snippets else item.get("description", "") + results.append( + SearchResult( + title=item.get("title", ""), + url=item.get("url", ""), + snippet=snippet, + date=item.get("page_age"), + last_updated=None, + ) + ) + + return SearchResponse( + results=results, + object="search", + ) diff --git a/litellm/proxy/example_config_yaml/websearch_interception_config.yaml b/litellm/proxy/example_config_yaml/websearch_interception_config.yaml index 97267994d74..3b9e4e9ef50 100644 --- a/litellm/proxy/example_config_yaml/websearch_interception_config.yaml +++ b/litellm/proxy/example_config_yaml/websearch_interception_config.yaml @@ -8,6 +8,10 @@ search_tools: - search_tool_name: "my-perplexity-search" litellm_params: search_provider: "perplexity" + # Alternative provider example (requires YOUCOM_API_KEY): + # - search_tool_name: "my-you-com-search" + # litellm_params: + # search_provider: "you_com" litellm_settings: callbacks: ["websearch_interception"] diff --git a/litellm/types/utils.py b/litellm/types/utils.py index abde978375e..93eef2d1d01 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3417,6 +3417,7 @@ class SearchProviders(str, Enum): DUCKDUCKGO = "duckduckgo" SEARCHAPI = "searchapi" SERPER = "serper" + YOU_COM = "you_com" APISERPENT = "apiserpent" diff --git a/litellm/utils.py b/litellm/utils.py index d2f3ae10288..7312e71bbd1 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9524,6 +9524,7 @@ class ProviderConfigManager: from litellm.llms.searxng.search.transformation import SearXNGSearchConfig from litellm.llms.serper.search.transformation import SerperSearchConfig from litellm.llms.tavily.search.transformation import TavilySearchConfig + from litellm.llms.you_com.search.transformation import YouComSearchConfig PROVIDER_TO_CONFIG_MAP = { SearchProviders.PERPLEXITY: PerplexitySearchConfig, @@ -9539,6 +9540,7 @@ class ProviderConfigManager: SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig, SearchProviders.SEARCHAPI: SearchAPIConfig, SearchProviders.SERPER: SerperSearchConfig, + SearchProviders.YOU_COM: YouComSearchConfig, SearchProviders.APISERPENT: APISerpentSearchConfig, } config_class = PROVIDER_TO_CONFIG_MAP.get(provider, None) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index aabc5b14b0d..a0ce368dfea 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -31023,6 +31023,11 @@ "litellm_provider": "tavily", "mode": "search" }, + "you_com/search": { + "input_cost_per_query": 0.0, + "litellm_provider": "you_com", + "mode": "search" + }, "text-completion-codestral/codestral-2405": { "input_cost_per_token": 0.0, "litellm_provider": "text-completion-codestral", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 28c3557bdbb..70e980ab2a0 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -2206,6 +2206,10 @@ "search": true } }, + "you_com": { + "display_name": "You.com (`you_com`)", + "url": "https://docs.litellm.ai/docs/search/you_com" + }, "apiserpent": { "display_name": "APISerpent (`apiserpent`)", "url": "https://docs.litellm.ai/docs/search/apiserpent", diff --git a/tests/test_litellm/llms/you_com/__init__.py b/tests/test_litellm/llms/you_com/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/you_com/test_you_com_search.py b/tests/test_litellm/llms/you_com/test_you_com_search.py new file mode 100644 index 00000000000..b263375ef7d --- /dev/null +++ b/tests/test_litellm/llms/you_com/test_you_com_search.py @@ -0,0 +1,330 @@ +""" +Tests for You.com Search API integration. +""" + +import os +import sys +import pytest +from unittest.mock import AsyncMock, patch, MagicMock + +sys.path.insert(0, os.path.abspath("../..")) + +import litellm + + +class TestYouComSearch: + """ + Tests for You.com Search functionality with mocked network responses. + """ + + @pytest.fixture(autouse=True) + def _set_api_key(self, monkeypatch): + """ + Default fixture: YOUCOM_API_KEY is set, scoped to this test. + Tests that need the key absent should call `monkeypatch.delenv` themselves. + """ + monkeypatch.setenv("YOUCOM_API_KEY", "test-api-key") + + @pytest.mark.asyncio + async def test_you_com_search_request_payload(self): + """ + Validate the You.com search request payload structure without real API calls. + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "results": { + "web": [ + { + "title": "Test Result 1", + "url": "https://example.com/1", + "description": "Brief description 1", + "snippets": ["This is a test snippet for result 1"], + "page_age": "2025-01-15T00:00:00Z", + }, + { + "title": "Test Result 2", + "url": "https://example.com/2", + "description": "Brief description 2", + "snippets": ["This is a test snippet for result 2"], + "page_age": "2025-01-10T00:00:00Z", + }, + ], + "news": [], + }, + "metadata": { + "search_uuid": "abc-123", + "query": "latest developments in AI", + "latency": 0.42, + }, + } + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = mock_response + + response = await litellm.asearch( + query="latest developments in AI", + search_provider="you_com", + max_results=5, + ) + + assert mock_post.call_count == 1 + call_args = mock_post.call_args + + assert call_args.kwargs["url"] == "https://ydc-index.io/v1/search" + + headers = call_args.kwargs.get("headers", {}) + assert "X-API-Key" in headers + assert headers["X-API-Key"] == "test-api-key" + assert headers["Content-Type"] == "application/json" + + json_data = call_args.kwargs.get("json") + assert json_data is not None + assert json_data["query"] == "latest developments in AI" + # max_results is mapped to You.com's `count` parameter + assert json_data["count"] == 5 + + assert hasattr(response, "results") + assert hasattr(response, "object") + assert response.object == "search" + assert len(response.results) == 2 + + first_result = response.results[0] + assert first_result.title == "Test Result 1" + assert first_result.url == "https://example.com/1" + assert first_result.snippet == "This is a test snippet for result 1" + assert first_result.date == "2025-01-15T00:00:00Z" + + @pytest.mark.asyncio + async def test_you_com_search_domain_filter_and_country(self): + """ + Validate that Perplexity-spec optional params map to You.com's parameters: + - search_domain_filter -> include_domains + - country -> country (lowercased to match Tavily's convention) + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "results": {"web": [], "news": []}, + "metadata": {}, + } + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = mock_response + + await litellm.asearch( + query="machine learning", + search_provider="you_com", + search_domain_filter=["arxiv.org", "nature.com"], + country="US", + ) + + call_args = mock_post.call_args + json_data = call_args.kwargs.get("json") + + assert json_data["query"] == "machine learning" + assert json_data["include_domains"] == ["arxiv.org", "nature.com"] + # Country is normalized to lowercase, matching Tavily's behavior. + assert json_data["country"] == "us" + # search_domain_filter and max_tokens_per_page (perplexity-spec names) + # should NOT leak through to the upstream payload. + assert "search_domain_filter" not in json_data + assert "max_tokens_per_page" not in json_data + + @pytest.mark.asyncio + async def test_you_com_search_snippet_fallback_to_description(self): + """ + When `snippets` is missing/empty, snippet falls back to `description`. + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "results": { + "web": [ + { + "title": "No snippets here", + "url": "https://example.com/3", + "description": "Fallback description text", + "snippets": [], + "page_age": None, + } + ], + "news": [], + }, + "metadata": {}, + } + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = mock_response + + response = await litellm.asearch( + query="anything", + search_provider="you_com", + ) + + assert len(response.results) == 1 + assert response.results[0].snippet == "Fallback description text" + assert response.results[0].date is None + + @pytest.mark.asyncio + async def test_you_com_search_news_results_appended(self): + """ + News results are flattened in after web results. + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "results": { + "web": [ + { + "title": "Web Result", + "url": "https://example.com/web", + "snippets": ["web snippet"], + "description": "web desc", + "page_age": "2025-01-01T00:00:00Z", + } + ], + "news": [ + { + "title": "News Result", + "url": "https://news.example.com/article", + "description": "news desc", + "page_age": "2025-02-01T00:00:00Z", + } + ], + }, + "metadata": {}, + } + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = mock_response + + response = await litellm.asearch( + query="anything", + search_provider="you_com", + ) + + assert len(response.results) == 2 + assert response.results[0].title == "Web Result" + assert response.results[1].title == "News Result" + # News result has no `snippets` -> falls back to description + assert response.results[1].snippet == "news desc" + + def test_you_com_search_complete_url_handles_trailing_slash(self): + """ + get_complete_url must normalize trailing slashes on api_base, so a custom + base like `https://x.example/v1/search/` does not become + `https://x.example/v1/search/v1/search`. + """ + from litellm.llms.you_com.search.transformation import YouComSearchConfig + + config = YouComSearchConfig() + assert ( + config.get_complete_url( + api_base="https://x.example/v1/search/", optional_params={} + ) + == "https://x.example/v1/search" + ) + assert ( + config.get_complete_url(api_base="https://x.example/", optional_params={}) + == "https://x.example/v1/search" + ) + # With an API key configured, default base is the keyed endpoint. + assert ( + config.get_complete_url(api_base=None, optional_params={}) + == "https://ydc-index.io/v1/search" + ) + + @pytest.mark.asyncio + async def test_you_com_search_keyless_free_tier(self, monkeypatch): + """ + Without YOUCOM_API_KEY, the adapter targets the keyless free-tier + endpoint and sends no X-API-Key header. + """ + monkeypatch.delenv("YOUCOM_API_KEY", raising=False) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "results": { + "web": [ + { + "title": "Keyless Result", + "url": "https://example.com/keyless", + "snippets": ["snippet from keyless tier"], + "description": "desc", + "page_age": "2025-03-01T00:00:00Z", + } + ], + "news": [], + }, + "metadata": {}, + } + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = mock_response + + response = await litellm.asearch( + query="hello world", + search_provider="you_com", + ) + + call_args = mock_post.call_args + assert ( + call_args.kwargs["url"] == "https://api.you.com/v1/agents/search" + ) + headers = call_args.kwargs.get("headers", {}) + assert "X-API-Key" not in headers + assert headers["Content-Type"] == "application/json" + + assert len(response.results) == 1 + assert response.results[0].title == "Keyless Result" + + def test_you_com_search_validate_environment_keyless(self, monkeypatch): + """ + validate_environment must NOT raise when no key is configured — + the keyless free tier is the default behavior. + """ + monkeypatch.delenv("YOUCOM_API_KEY", raising=False) + + from litellm.llms.you_com.search.transformation import YouComSearchConfig + + config = YouComSearchConfig() + headers = config.validate_environment(headers={}, api_key=None) + assert "X-API-Key" not in headers + assert headers["Content-Type"] == "application/json" + + def test_you_com_search_pins_identity_accept_encoding(self, monkeypatch): + """ + The adapter pins Accept-Encoding: identity to work around the keyless + endpoint advertising gzip content-encoding while returning bytes httpx + can't decode. Without this, every keyless request raises DecodingError. + """ + monkeypatch.delenv("YOUCOM_API_KEY", raising=False) + + from litellm.llms.you_com.search.transformation import YouComSearchConfig + + config = YouComSearchConfig() + headers = config.validate_environment(headers={}, api_key=None) + assert headers["Accept-Encoding"] == "identity" + + # setdefault: a caller-supplied Accept-Encoding should win + headers = config.validate_environment( + headers={"Accept-Encoding": "gzip"}, api_key=None + ) + assert headers["Accept-Encoding"] == "gzip"