mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
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:
parent
53e1e9b87b
commit
9ef5542339
16 changed files with 611 additions and 2 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
0
litellm/llms/webiq/__init__.py
Normal file
0
litellm/llms/webiq/__init__.py
Normal file
0
litellm/llms/webiq/search/__init__.py
Normal file
0
litellm/llms/webiq/search/__init__.py
Normal file
156
litellm/llms/webiq/search/transformation.py
Normal file
156
litellm/llms/webiq/search/transformation.py
Normal 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
|
||||
],
|
||||
}
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -4869,6 +4869,7 @@ class SearchProviders(str, Enum):
|
|||
AGENTCORE = "agentcore"
|
||||
NIMBLE = "nimble"
|
||||
BING_GROUNDING = "bing_grounding"
|
||||
WEBIQ = "webiq"
|
||||
DEEPSEEK = "deepseek"
|
||||
XAI = "xai"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
0
tests/unit/llms/webiq/__init__.py
Normal file
0
tests/unit/llms/webiq/__init__.py
Normal file
0
tests/unit/llms/webiq/search/__init__.py
Normal file
0
tests/unit/llms/webiq/search/__init__.py
Normal file
351
tests/unit/llms/webiq/search/test_webiq_search_transformation.py
Normal file
351
tests/unit/llms/webiq/search/test_webiq_search_transformation.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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"}]})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue