From 9ef55423392dd042e6e2b256b328520d3f77349f Mon Sep 17 00:00:00 2001 From: giladk1221 Date: Wed, 11 Nov 2026 00:43:36 +0200 Subject: [PATCH] feat(search): add Microsoft Web IQ provider (#50883) * feat(search): add Microsoft Web IQ provider * fix(search): require HTTPS for Web IQ credentials * fix(search): include Web IQ in the packaged endpoint catalog * fix(search): register Microsoft Web IQ query pricing * test(search): fix Web IQ exception assertion lint * fix(search): type Web IQ response passthrough and search error headers The type-check gate failed on 8 new unknown-type errors in the Web IQ transformation. Build passthrough fields from a typed helper and validate the response models directly, and type the base search error headers as dict[str, str] so subclasses no longer inherit an unknown parameter. * fix(search): raise Web IQ parse errors without widening the base header type Typing the base search error headers as dict[str, str] made the shared HTTP handler report one more argument-type error. Restore the base signature and raise the same BaseLLMException directly from Web IQ. --------- Co-authored-by: Gilad Katz <29776916+giladk1221@users.noreply.github.com> Co-authored-by: ryan-crabbe-berri --- .github/workflows/test-unit.yml | 1 + litellm/llms/webiq/__init__.py | 0 litellm/llms/webiq/search/__init__.py | 0 litellm/llms/webiq/search/transformation.py | 156 ++++++++ ...odel_prices_and_context_window_backup.json | 5 + .../provider_endpoints_support_backup.json | 26 ++ litellm/types/utils.py | 1 + litellm/utils.py | 2 + model_prices_and_context_window.json | 5 + provider_endpoints_support.json | 26 ++ tests/unit/llms/webiq/__init__.py | 0 tests/unit/llms/webiq/search/__init__.py | 0 .../test_webiq_search_transformation.py | 351 ++++++++++++++++++ .../test_search_tool_management.py | 11 + .../public_endpoints/test_public_endpoints.py | 16 +- tests/unit/search/test_main.py | 13 + 16 files changed, 611 insertions(+), 2 deletions(-) create mode 100644 litellm/llms/webiq/__init__.py create mode 100644 litellm/llms/webiq/search/__init__.py create mode 100644 litellm/llms/webiq/search/transformation.py create mode 100644 tests/unit/llms/webiq/__init__.py create mode 100644 tests/unit/llms/webiq/search/__init__.py create mode 100644 tests/unit/llms/webiq/search/test_webiq_search_transformation.py diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index fb29a92e306..d3f4821a05b 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -397,6 +397,7 @@ jobs: tests/unit/llms/voyage tests/unit/llms/wandb tests/unit/llms/watsonx + tests/unit/llms/webiq tests/unit/llms/xai tests/unit/llms/xinference tests/unit/llms/you_com diff --git a/litellm/llms/webiq/__init__.py b/litellm/llms/webiq/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/webiq/search/__init__.py b/litellm/llms/webiq/search/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/webiq/search/transformation.py b/litellm/llms/webiq/search/transformation.py new file mode 100644 index 00000000000..85d122a21af --- /dev/null +++ b/litellm/llms/webiq/search/transformation.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import TYPE_CHECKING, Final +from urllib.parse import urlsplit + +import httpx +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError + +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.search.transformation import BaseSearchConfig, SearchResponse, SearchResult + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + + +class _WebIQResult(BaseModel): + model_config = ConfigDict(extra="allow", frozen=True) + + title: str + url: str + content: str + lastUpdatedAt: str | None = None + + +class _WebIQResponse(BaseModel): + model_config = ConfigDict(extra="allow", frozen=True) + + webResults: tuple[_WebIQResult, ...] + + +_DOMAINS: Final = TypeAdapter(tuple[str, ...]) + + +def _extra_fields(model: BaseModel, reserved: Mapping[str, object]) -> MappingProxyType[str, object]: + extra: Final[Mapping[str, object]] = model.model_extra or {} + return MappingProxyType({key: value for key, value in extra.items() if key not in reserved}) + + +def _query_with_domains(query: str, domains: object) -> str: + if domains is None: + return query + parsed: Final = _DOMAINS.validate_python(domains) + included: Final = tuple(f"site:{domain}" for domain in parsed if domain and not domain.startswith("-")) + excluded: Final = tuple(f"-site:{domain[1:]}" for domain in parsed if domain.startswith("-") and len(domain) > 1) + include_clause: Final = f"({' OR '.join(included)})" if included else "" + if not included and not excluded: + return query + return " ".join(part for part in (f"({query})", include_clause, *excluded) if part) + + +class WebIQSearchConfig(BaseSearchConfig): + DEFAULT_API_BASE: Final = "https://api.microsoft.ai/v3" + + @staticmethod + def ui_friendly_name() -> str: + return "Microsoft Web IQ" + + def validate_environment( + self, + headers: dict[str, str], # mutable-ok: BaseSearchConfig interface + api_key: str | None = None, + api_base: str | None = None, + **kwargs: object, # kwargs-ok: BaseSearchConfig interface + ) -> dict[str, str]: # mutable-ok: HTTP handler requires a dict + if urlsplit(api_base or self.DEFAULT_API_BASE).scheme != "https": + raise ValueError("Microsoft Web IQ search requires an HTTPS api_base") + resolved_key: Final = self.resolve_server_api_key( + caller_api_key=api_key, + caller_api_base=api_base, + key_env_vars=("WEBIQ_API_KEY",), + base_env_var=None, + default_api_base=self.DEFAULT_API_BASE, + ) + if not resolved_key: + raise ValueError("Set WEBIQ_API_KEY or pass api_key for Microsoft Web IQ search") + return { + **headers, + "x-apikey": resolved_key, + "Content-Type": "application/json", + } + + def get_complete_url( + self, + api_base: str | None, + optional_params: dict[str, object], # mutable-ok: BaseSearchConfig interface + data: dict[str, object] | list[dict[str, object]] | None = None, # mutable-ok: BaseSearchConfig interface + **kwargs: object, # kwargs-ok: BaseSearchConfig interface + ) -> str: + base: Final = (api_base or self.DEFAULT_API_BASE).rstrip("/") + return base if base.endswith("/search/web") else f"{base}/search/web" + + def transform_search_request( + self, + query: str | list[str], # mutable-ok: BaseSearchConfig interface + optional_params: dict[str, object], # mutable-ok: BaseSearchConfig interface + **kwargs: object, # kwargs-ok: BaseSearchConfig interface + ) -> dict[str, object]: # mutable-ok: HTTP handler requires a JSON dict + country: Final = optional_params.get("country") + mapped: Final = MappingProxyType( + { + "maxResults": optional_params.get("max_results"), + "region": country.upper() if isinstance(country, str) else None, + } + ) + native: Final = MappingProxyType( + { + key: value + for key, value in optional_params.items() + if key not in self.get_supported_perplexity_optional_params() + } + ) + return { + "contentFormat": "passage", + "maxLength": 5000, + **MappingProxyType({key: value for key, value in mapped.items() if value is not None}), + **native, + "query": _query_with_domains( + " ".join(query) if isinstance(query, list) else query, + optional_params.get("search_domain_filter"), + ), + } + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs: object, # kwargs-ok: BaseSearchConfig interface + ) -> SearchResponse: + try: + parsed: Final = _WebIQResponse.model_validate_json(raw_response.content) + except ValidationError as exc: + raise BaseLLMException( + status_code=502, + message=f"Microsoft Web IQ returned an invalid search response: {exc}", + headers=dict(raw_response.headers), + ) from exc + return SearchResponse.model_validate( + { + **_extra_fields(parsed, SearchResponse.model_fields), + "results": [ + SearchResult.model_validate( + { + **_extra_fields(result, SearchResult.model_fields), + "title": result.title, + "url": result.url, + "snippet": result.content, + "date": result.lastUpdatedAt or None, + "last_updated": result.lastUpdatedAt or None, + } + ) + for result in parsed.webResults + ], + } + ) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 40ff675de19..83ab91f2bc0 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -25306,6 +25306,11 @@ "notes": "Firecrawl search pricing: $83 for 100,000 credits, 2 credits per 10 results. Cost = ceiling(limit/10) * 2 * $0.00083" } }, + "webiq/search": { + "input_cost_per_query": 0.007, + "litellm_provider": "webiq", + "mode": "search" + }, "perplexity/search": { "input_cost_per_query": 0.005, "litellm_provider": "perplexity", diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 00fee0a0d5a..3431cc97969 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -4098,6 +4098,32 @@ "generateContent": false } }, + "webiq": { + "display_name": "Microsoft Web IQ (`webiq`)", + "url": "https://learn.microsoft.com/en-us/web-iq/reference/web-search", + "endpoints": { + "chat_completions": false, + "responses": false, + "text_completion": false, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": false, + "audio_speech": false, + "rerank": false, + "ocr": false, + "search": true, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": false, + "interactions": false, + "systemone": false, + "a2a": false, + "generateContent": false + } + }, "tavily": { "display_name": "Tavily (`tavily`)", "url": "https://docs.litellm.ai/docs/search/tavily", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 6325de96839..addd642eca7 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4869,6 +4869,7 @@ class SearchProviders(str, Enum): AGENTCORE = "agentcore" NIMBLE = "nimble" BING_GROUNDING = "bing_grounding" + WEBIQ = "webiq" DEEPSEEK = "deepseek" XAI = "xai" diff --git a/litellm/utils.py b/litellm/utils.py index a3a1fe5ede4..4a4f2e8edfe 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -11357,6 +11357,7 @@ class ProviderConfigManager: from litellm.llms.serper.search.transformation import SerperSearchConfig from litellm.llms.tavily.search.transformation import TavilySearchConfig from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig + from litellm.llms.webiq.search.transformation import WebIQSearchConfig from litellm.llms.xai.search.transformation import XAISearchConfig from litellm.llms.you_com.search.transformation import YouComSearchConfig @@ -11381,6 +11382,7 @@ class ProviderConfigManager: SearchProviders.AGENTCORE: AgentCoreSearchConfig, SearchProviders.NIMBLE: NimbleSearchConfig, SearchProviders.BING_GROUNDING: BingGroundingSearchConfig, + SearchProviders.WEBIQ: WebIQSearchConfig, SearchProviders.DEEPSEEK: DeepSeekSearchConfig, SearchProviders.XAI: XAISearchConfig, } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 40ff675de19..83ab91f2bc0 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -25306,6 +25306,11 @@ "notes": "Firecrawl search pricing: $83 for 100,000 credits, 2 credits per 10 results. Cost = ceiling(limit/10) * 2 * $0.00083" } }, + "webiq/search": { + "input_cost_per_query": 0.007, + "litellm_provider": "webiq", + "mode": "search" + }, "perplexity/search": { "input_cost_per_query": 0.005, "litellm_provider": "perplexity", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 00fee0a0d5a..3431cc97969 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -4098,6 +4098,32 @@ "generateContent": false } }, + "webiq": { + "display_name": "Microsoft Web IQ (`webiq`)", + "url": "https://learn.microsoft.com/en-us/web-iq/reference/web-search", + "endpoints": { + "chat_completions": false, + "responses": false, + "text_completion": false, + "embeddings": false, + "image_generations": false, + "image_edits": false, + "audio_transcriptions": false, + "audio_speech": false, + "rerank": false, + "ocr": false, + "search": true, + "moderations": false, + "realtime": false, + "video_generations": false, + "batches": false, + "messages": false, + "interactions": false, + "systemone": false, + "a2a": false, + "generateContent": false + } + }, "tavily": { "display_name": "Tavily (`tavily`)", "url": "https://docs.litellm.ai/docs/search/tavily", diff --git a/tests/unit/llms/webiq/__init__.py b/tests/unit/llms/webiq/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/webiq/search/__init__.py b/tests/unit/llms/webiq/search/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/webiq/search/test_webiq_search_transformation.py b/tests/unit/llms/webiq/search/test_webiq_search_transformation.py new file mode 100644 index 00000000000..64ef26bacae --- /dev/null +++ b/tests/unit/llms/webiq/search/test_webiq_search_transformation.py @@ -0,0 +1,351 @@ +import json +from collections.abc import Iterator +from typing import Final + +import openai +import pytest +import respx + +import litellm +from litellm.llms.base_llm.search.transformation import SearchResponse +from litellm.llms.webiq.search.transformation import WebIQSearchConfig + + +@pytest.fixture(autouse=True) +def webiq_environment(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.delenv("WEBIQ_API_KEY", raising=False) + monkeypatch.setattr( # test-quality-ok: select HTTPX so respx can intercept the external HTTP boundary + litellm, "disable_aiohttp_transport", True + ) + litellm.in_memory_llm_clients_cache.flush_cache() + yield + litellm.in_memory_llm_clients_cache.flush_cache() + + +def test_search_maps_request_and_preserves_sources(respx_mock: respx.MockRouter) -> None: + endpoint: Final = f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web" + route: Final = respx_mock.post(endpoint).respond( + json={ + "webResults": [ + { + "title": "A source", + "url": "https://example.com/article", + "content": "Relevant passage", + "lastUpdatedAt": "2026-01-02T00:00:00Z", + "crawledAt": "2026-01-03T00:00:00Z", + "language": "en", + "contentTier": "standard", + "instrumentationSuffix": "source-1", + } + ], + "traceId": "trace-1", + "querySignals": {"freshness": True}, + } + ) + + response: Final = litellm.search( + query="RAG research", + search_provider="webiq", + api_key="test-key", + max_results=3, + country="gb", + search_domain_filter=["example.com", "example.org", "-excluded.example"], + language="en", + ) + + assert json.loads(route.calls.last.request.content) == { + "query": "(RAG research) (site:example.com OR site:example.org) -site:excluded.example", + "maxResults": 3, + "region": "GB", + "contentFormat": "passage", + "maxLength": 5000, + "language": "en", + } + assert route.calls.last.request.headers["x-apikey"] == "test-key" + assert response.model_dump() == { + "object": "search", + "results": [ + { + "title": "A source", + "url": "https://example.com/article", + "snippet": "Relevant passage", + "date": "2026-01-02T00:00:00Z", + "last_updated": "2026-01-02T00:00:00Z", + "crawledAt": "2026-01-03T00:00:00Z", + "language": "en", + "contentTier": "standard", + "instrumentationSuffix": "source-1", + } + ], + "traceId": "trace-1", + "querySignals": {"freshness": True}, + } + + +@pytest.mark.parametrize("api_base", [None, "https://api.microsoft.ai/v3", "https://api.microsoft.ai/v3/search/web"]) +async def test_asearch_uses_environment_credentials_at_default_endpoint( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, api_base: str | None +) -> None: + monkeypatch.setenv("WEBIQ_API_KEY", "environment-key") + route: Final = respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond(json={"webResults": []}) + + response: Final = await litellm.asearch(query="no matches", search_provider="webiq", api_base=api_base) + + assert response.model_dump() == {"object": "search", "results": []} + assert route.calls.last.request.headers["x-apikey"] == "environment-key" + assert json.loads(route.calls.last.request.content) == { + "query": "no matches", + "contentFormat": "passage", + "maxLength": 5000, + } + + +async def test_asearch_uses_explicit_credentials_at_custom_endpoint(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post("https://webiq.example/v3/search/web").respond(json={"webResults": []}) + + response: Final = await litellm.asearch( + query="no matches", search_provider="webiq", api_key="custom-key", api_base="https://webiq.example/v3/" + ) + + assert response.model_dump() == {"object": "search", "results": []} + assert route.calls.last.request.headers["x-apikey"] == "custom-key" + assert json.loads(route.calls.last.request.content) == { + "query": "no matches", + "contentFormat": "passage", + "maxLength": 5000, + } + + +@pytest.mark.parametrize("updated", [None, "", "2026-02-01T00:00:00Z"]) +def test_optional_updated_date_never_uses_crawl_time(respx_mock: respx.MockRouter, updated: str | None) -> None: + respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond( + json={ + "webResults": [ + { + "title": "Title", + "url": "https://example.com", + "content": "Passage", + "lastUpdatedAt": updated, + "crawledAt": "2026-03-01T00:00:00Z", + } + ] + } + ) + response: Final = litellm.search(query="query", search_provider="webiq", api_key="key") + assert (response.results[0].date, response.results[0].last_updated) == (updated or None, updated or None) + + +def test_missing_optional_dates_and_result_order(respx_mock: respx.MockRouter) -> None: + respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond( + json={ + "webResults": [ + {"title": title, "url": f"https://example.com/{title}", "content": title} + for title in ("first", "second") + ] + } + ) + response: Final = litellm.search(query="query", search_provider="webiq", api_key="key") + assert [(r.title, r.snippet, r.date, r.last_updated) for r in response.results] == [ + ("first", "first", None, None), + ("second", "second", None, None), + ] + + +@pytest.mark.parametrize("body", ["not json", "{}", '{"webResults": null}', '{"webResults": [{}]}']) +def test_malformed_response_is_not_reported_as_empty_success(respx_mock: respx.MockRouter, body: str) -> None: + respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond(text=body) + with pytest.raises(openai.APIError, match="invalid search response") as error: + litellm.search(query="query", search_provider="webiq", api_key="key") + assert error.value.status_code == 502 + + +@pytest.mark.parametrize("status", [401, 403, 429, 503]) +def test_upstream_http_errors_keep_status(respx_mock: respx.MockRouter, status: int) -> None: + respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond( + status_code=status, json={"error": {"message": "upstream failure"}}, headers={"Retry-After": "2"} + ) + with pytest.raises(openai.APIError, match="upstream failure") as error: + litellm.search(query="query", search_provider="webiq", api_key="key") + assert error.value.status_code == status + + +def test_native_options_override_mapped_defaults_without_mutating_input() -> None: + params: Final = { + "max_results": 3, + "maxResults": 7, + "country": "us", + "region": "JP", + "maxLength": 2000, + "max_tokens_per_page": 100, + "contentFormat": "markdown", + "safeSearch": "strict", + } + before: Final = params.copy() + assert WebIQSearchConfig().transform_search_request(["first", "second"], params) == { + "query": "first second", + "maxResults": 7, + "region": "JP", + "maxLength": 2000, + "contentFormat": "markdown", + "safeSearch": "strict", + } + assert params == before + + +@pytest.mark.parametrize( + "domains, expected", + [ + ([], "query"), + ([""], "query"), + (["-"], "query"), + (["-example.com"], "(query) -site:example.com"), + (["example.com"], "(query) (site:example.com)"), + ], +) +def test_domain_filter_queries(domains: list[str], expected: str) -> None: + assert WebIQSearchConfig().transform_search_request("query", {"search_domain_filter": domains}) == { + "query": expected, + "contentFormat": "passage", + "maxLength": 5000, + } + + +@pytest.mark.parametrize("suffix", ["", "/", "/search/web", "/search/web/"]) +def test_endpoint_is_appended_once(suffix: str) -> None: + assert WebIQSearchConfig().get_complete_url(f"https://webiq.example/v3{suffix}", {}) == ( + "https://webiq.example/v3/search/web" + ) + + +def test_environment_key_cannot_be_sent_to_caller_selected_host(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("WEBIQ_API_KEY", "server-key") + with pytest.raises(ValueError, match="Refusing to send"): + WebIQSearchConfig().validate_environment({}, api_base="https://untrusted.example/v3") + + +@pytest.mark.parametrize("use_async", [False, True]) +@pytest.mark.parametrize("api_key", [None, "caller-key"]) +async def test_http_endpoint_rejected_before_sending_credentials( + monkeypatch: pytest.MonkeyPatch, use_async: bool, api_key: str | None +) -> None: + monkeypatch.setenv("WEBIQ_API_KEY", "server-key") + with respx.mock(assert_all_called=False) as transport: + transport.post("http://api.microsoft.ai/v3/search/web").respond(json={"webResults": []}) + if use_async: + with pytest.raises(openai.APIError, match="HTTPS"): + await litellm.asearch( + query="query", search_provider="webiq", api_base="http://api.microsoft.ai/v3", api_key=api_key + ) + else: + with pytest.raises(openai.APIError, match="HTTPS"): + litellm.search( + query="query", search_provider="webiq", api_base="http://api.microsoft.ai/v3", api_key=api_key + ) + assert len(transport.calls) == 0 + + +def test_explicit_key_wins_and_headers_are_not_mutated(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("WEBIQ_API_KEY", "server-key") + config: Final = WebIQSearchConfig() + original: Final = {"X-Request-ID": "request-1"} + result: Final = config.validate_environment(original, api_key="caller-key", api_base="https://custom.example/v3") + assert result == {"X-Request-ID": "request-1", "x-apikey": "caller-key", "Content-Type": "application/json"} + assert original == {"X-Request-ID": "request-1"} + assert config.validate_environment(result, api_key="caller-key") == result + + +def test_missing_key_fails_before_network() -> None: + with pytest.raises(ValueError, match="WEBIQ_API_KEY"): + WebIQSearchConfig().validate_environment({}) + + +@pytest.mark.parametrize( + "result_metadata, response_metadata", + [ + ({"snippet": "upstream", "date": "upstream", "last_updated": "upstream"}, {}), + ({}, {"results": "upstream", "object": "upstream"}), + ], +) +def test_metadata_cannot_replace_standard_search_fields( + respx_mock: respx.MockRouter, result_metadata: dict[str, str], response_metadata: dict[str, str] +) -> None: + respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond( + json={ + "webResults": [ + { + "title": "Title", + "url": "https://example.com", + "content": "Passage", + "lastUpdatedAt": "2026-01-02T00:00:00Z", + "language": "en", + **result_metadata, + } + ], + "traceId": "trace-1", + **response_metadata, + } + ) + + response: Final = litellm.search(query="query", search_provider="webiq", api_key="key") + + assert response.model_dump() == { + "object": "search", + "results": [ + { + "title": "Title", + "url": "https://example.com", + "snippet": "Passage", + "date": "2026-01-02T00:00:00Z", + "last_updated": "2026-01-02T00:00:00Z", + "language": "en", + } + ], + "traceId": "trace-1", + } + + +@pytest.mark.parametrize("tool_parameter", ["model", "search_tool_name"]) +async def test_router_resolves_named_webiq_tool_and_forwards_its_configuration( + respx_mock: respx.MockRouter, tool_parameter: str +) -> None: + route: Final = respx_mock.post("https://webiq.example/v3/search/web").respond( + json={"webResults": [{"title": "Source", "url": "https://example.com", "content": "Grounding"}]} + ) + router: Final = litellm.Router( + model_list=[], + search_tools=[ + { + "search_tool_name": "webiq-search", + "litellm_params": { + "search_provider": "webiq", + "api_key": "tool-key", + "api_base": "https://webiq.example/v3", + "maxLength": 1500, + }, + } + ], + num_retries=0, + ) + + response: Final = await router.asearch(**{tool_parameter: "webiq-search"}, query="ground this", max_results=2) + + assert isinstance(response, SearchResponse) + assert response.model_dump() == { + "object": "search", + "results": [ + { + "title": "Source", + "url": "https://example.com", + "snippet": "Grounding", + "date": None, + "last_updated": None, + } + ], + } + assert route.calls.last.request.headers["x-apikey"] == "tool-key" + assert json.loads(route.calls.last.request.content) == { + "query": "ground this", + "contentFormat": "passage", + "maxLength": 1500, + "maxResults": 2, + } diff --git a/tests/unit/proxy/management_endpoints/search_endpoints/test_search_tool_management.py b/tests/unit/proxy/management_endpoints/search_endpoints/test_search_tool_management.py index 65ebece406a..39d8f12be53 100644 --- a/tests/unit/proxy/management_endpoints/search_endpoints/test_search_tool_management.py +++ b/tests/unit/proxy/management_endpoints/search_endpoints/test_search_tool_management.py @@ -3,6 +3,7 @@ import json from collections.abc import Callable from datetime import datetime from types import SimpleNamespace +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -27,6 +28,16 @@ from tests.unit.proxy.router_entry_point_stubs import route_entry_points_to_stub client = TestClient(app) +def test_dashboard_discovers_webiq_with_its_display_name() -> None: + with _override_auth(UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)): + response: Final = client.get("/search_tools/ui/available_providers") + + assert response.status_code == 200, response.text + assert [provider for provider in response.json()["providers"] if provider["provider_name"] == "webiq"] == [ + {"provider_name": "webiq", "ui_friendly_name": "Microsoft Web IQ"} + ] + + @pytest.mark.asyncio async def test_list_search_tools_db_only(monkeypatch): """Test listing search tools when only DB tools exist""" diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index 27ba8968200..66096feb570 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -1,5 +1,3 @@ -from litellm.proxy.deps.repositories import get_plugin_upload_repository -from litellm.repositories.plugin_upload_repository import PluginUploadRepository import asyncio import json import re @@ -20,7 +18,9 @@ from pydantic import TypeAdapter import litellm from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.health_check_latest import LatestHealthCheckRow +from litellm.proxy.deps.repositories import get_plugin_upload_repository from litellm.proxy.public_endpoints import router +from litellm.repositories.plugin_upload_repository import PluginUploadRepository from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_presets from litellm.types.proxy.common_proxy_errors import CommonProxyErrors from litellm.types.proxy.management_endpoints.model_management_endpoints import ( @@ -1285,6 +1285,18 @@ def test_get_supported_endpoints_lists_streamlake_on_chat_messages_and_responses assert "embeddings" not in streamlake_endpoints +def test_get_supported_endpoints_lists_webiq_search(reset_endpoints_cache: None) -> None: + app: Final = FastAPI() + app.include_router(router) + with TestClient(app) as client: + response: Final = client.get("/public/endpoints") + assert response.status_code == 200, response.text + + search: Final = next(endpoint for endpoint in response.json()["endpoints"] if endpoint["key"] == "search") + assert search["endpoint"] == "/search" + assert {"slug": "webiq", "display_name": "Microsoft Web IQ"} in search["providers"] + + def test_get_supported_endpoints_display_names_have_no_slug_suffix( reset_endpoints_cache, ): diff --git a/tests/unit/search/test_main.py b/tests/unit/search/test_main.py index e30fd2eb6f8..0201311d04b 100644 --- a/tests/unit/search/test_main.py +++ b/tests/unit/search/test_main.py @@ -56,6 +56,19 @@ def _proxy_style_logging_obj() -> Logging: ) +@pytest.mark.usefixtures("_local_model_cost_map") +def test_webiq_search_reports_the_catalog_query_cost(respx_mock: respx.MockRouter) -> None: + respx_mock.post("https://api.microsoft.ai/v3/search/web").respond( + json={"webResults": [{"title": "A source", "url": "https://example.com", "content": "A passage"}]} + ) + response: Final = litellm.search(query="litellm release notes", search_provider="webiq", api_key="test-webiq-key") + + assert response._hidden_params["response_cost"] > 0 + assert response._hidden_params["response_cost"] == pytest.approx( + litellm.model_cost["webiq/search"]["input_cost_per_query"] + ) + + @pytest.mark.usefixtures("_local_model_cost_map") def test_search_bills_search_tool_input_cost_per_query(respx_mock: respx.MockRouter) -> None: respx_mock.post(EXA_SEARCH_URL).respond(json={"results": [{"title": "t", "url": "https://example.com"}]})