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 <ryan@berri.ai>
This commit is contained in:
giladk1221 2026-11-11 00:43:36 +02:00 • committed by GitHub
parent 53e1e9b87b
commit 9ef5542339
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 611 additions and 2 deletions

View file

@ -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

View file

View file

View file

@ -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
],
}
)

View file

@ -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",

View file

@ -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",

View file

@ -4869,6 +4869,7 @@ class SearchProviders(str, Enum):
AGENTCORE = "agentcore"
NIMBLE = "nimble"
BING_GROUNDING = "bing_grounding"
WEBIQ = "webiq"
DEEPSEEK = "deepseek"
XAI = "xai"

View file

@ -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,
}

View file

@ -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",

View file

@ -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",

View file

View file

View file

@ -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,
}

View file

@ -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"""

View file

@ -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,
):

View file

@ -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"}]})