mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge pull request #38119 from BerriAI/litellm_bing_grounding_search_provider
feat(search): add Grounding with Bing Search (bing_grounding) as a search provider
This commit is contained in:
commit
da91d4b6c9
14 changed files with 1158 additions and 0 deletions
3
litellm/llms/azure/search/__init__.py
Normal file
3
litellm/llms/azure/search/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from litellm.llms.azure.search.transformation import BingGroundingSearchConfig
|
||||
|
||||
__all__ = ("BingGroundingSearchConfig",)
|
||||
442
litellm/llms/azure/search/transformation.py
Normal file
442
litellm/llms/azure/search/transformation.py
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
"""
|
||||
Calls the Microsoft Foundry Responses API with the `bing_grounding` or `web_search`
|
||||
tool to search the web (Grounding with Bing Search).
|
||||
|
||||
Microsoft docs: https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-grounding
|
||||
|
||||
Setup:
|
||||
1. Set BING_GROUNDING_PROJECT_ENDPOINT to the Foundry project endpoint, e.g.
|
||||
https://<account>.services.ai.azure.com/api/projects/<project>
|
||||
2. Set BING_GROUNDING_MODEL to a model deployment in that project (e.g. gpt-4.1);
|
||||
it runs the grounded search and its tokens are billed on that deployment
|
||||
3. Optional: set BING_GROUNDING_CONNECTION_ID to a Grounding with Bing Search
|
||||
project connection id to use the `bing_grounding` tool; without it the
|
||||
project's built-in `web_search` tool is used
|
||||
4. Auth: pass api_key (an Azure API key, sent in the api-key header), or set
|
||||
BING_GROUNDING_TOKEN to an Entra bearer token for scope
|
||||
https://ai.azure.com/.default, or configure azure-identity (AZURE_CLIENT_ID /
|
||||
AZURE_CLIENT_SECRET / AZURE_TENANT_ID, managed identity, or any
|
||||
DefaultAzureCredential source) and the token is minted automatically
|
||||
|
||||
Usage:
|
||||
response = litellm.search(
|
||||
query="latest AI developments",
|
||||
search_provider="bing_grounding",
|
||||
max_results=5,
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.search.transformation import (
|
||||
BaseSearchConfig,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
_DOCS_URL: Final = "https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/bing-grounding"
|
||||
|
||||
PROJECT_ENDPOINT_ENV: Final = "BING_GROUNDING_PROJECT_ENDPOINT"
|
||||
MODEL_ENV: Final = "BING_GROUNDING_MODEL"
|
||||
CONNECTION_ID_ENV: Final = "BING_GROUNDING_CONNECTION_ID"
|
||||
TOKEN_ENV: Final = "BING_GROUNDING_TOKEN"
|
||||
|
||||
ENTRA_SCOPE: Final = "https://ai.azure.com/.default"
|
||||
|
||||
_RESPONSES_PATH: Final = "/openai/v1/responses"
|
||||
_SNIPPET_FALLBACK_LENGTH: Final = 300
|
||||
_UPSTREAM_ERROR_STATUS: Final = 502
|
||||
_RESPONSE_COST_HEADER: Final = "llm_provider-x-litellm-response-cost"
|
||||
|
||||
|
||||
class _Annotation(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
type: str = ""
|
||||
url: str | None = None
|
||||
title: str | None = None
|
||||
start_index: int | None = None
|
||||
end_index: int | None = None
|
||||
|
||||
|
||||
class _ContentPart(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
type: str = ""
|
||||
text: str = ""
|
||||
annotations: tuple[_Annotation, ...] = ()
|
||||
|
||||
|
||||
class _OutputItem(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
type: str = ""
|
||||
content: tuple[_ContentPart, ...] = ()
|
||||
|
||||
|
||||
class _ErrorBody(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class _IncompleteDetails(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class _ResponsesEnvelope(BaseModel):
|
||||
"""A Foundry Responses API body. `output` is required: a body without it is not a
|
||||
Responses API response and must not be reported as a successful empty search.
|
||||
|
||||
A 200 body can still carry `status` `failed` or `incomplete`; those are surfaced as
|
||||
errors rather than reported as a successful empty search."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
output: tuple[_OutputItem, ...]
|
||||
status: str | None = None
|
||||
error: _ErrorBody | None = None
|
||||
incomplete_details: _IncompleteDetails | None = None
|
||||
|
||||
|
||||
class _ErrorEnvelope(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
error: _ErrorBody | None = None
|
||||
|
||||
|
||||
def _unwrap_error_detail(error_message: str) -> str:
|
||||
"""
|
||||
Surface the human-readable message inside Foundry's error envelope.
|
||||
|
||||
Tool failures nest a second JSON document as a string inside `error.message`
|
||||
(observed live for `bing_grounding` connection errors), so the unwrap runs twice.
|
||||
Falls back to the raw body for anything else.
|
||||
"""
|
||||
try:
|
||||
envelope: Final = _ErrorEnvelope.model_validate_json(error_message)
|
||||
except ValidationError:
|
||||
return error_message
|
||||
message: Final = envelope.error.message if envelope.error else None
|
||||
if message is None:
|
||||
return error_message
|
||||
try:
|
||||
nested: Final = _ErrorBody.model_validate_json(message)
|
||||
except ValidationError:
|
||||
return message
|
||||
return nested.message or message
|
||||
|
||||
|
||||
def _snippet(text: str, annotation: _Annotation) -> str:
|
||||
"""
|
||||
The text a citation supports, not the citation marker itself.
|
||||
|
||||
A url_citation's start/end indices span the inline marker ("([host](url))"),
|
||||
which follows the claim it backs, so the snippet is the marker's own line up
|
||||
to where the marker starts.
|
||||
"""
|
||||
start: Final = annotation.start_index
|
||||
marker_start: Final = start if start is not None and 0 <= start <= len(text) else len(text)
|
||||
claim: Final = text[:marker_start].rsplit("\n", 1)[-1].strip()
|
||||
if claim:
|
||||
return claim[-_SNIPPET_FALLBACK_LENGTH:]
|
||||
return text[:_SNIPPET_FALLBACK_LENGTH]
|
||||
|
||||
|
||||
def _citation_results(envelope: _ResponsesEnvelope) -> tuple[SearchResult, ...]:
|
||||
"""One result per cited URL: first occurrence wins, order preserved as answered."""
|
||||
cited: Final = tuple(
|
||||
SearchResult(
|
||||
title=annotation.title or "",
|
||||
url=annotation.url or "",
|
||||
snippet=_snippet(part.text, annotation),
|
||||
date=None,
|
||||
last_updated=None,
|
||||
)
|
||||
for item in envelope.output
|
||||
if item.type == "message"
|
||||
for part in item.content
|
||||
if part.type == "output_text"
|
||||
for annotation in part.annotations
|
||||
if annotation.type == "url_citation" and annotation.url
|
||||
)
|
||||
first_by_url: Final = MappingProxyType({result.url: result for result in reversed(cited)})
|
||||
return tuple(first_by_url[url] for url in dict.fromkeys(result.url for result in cited))
|
||||
|
||||
|
||||
def _valid_max_results(max_results: object) -> int | None:
|
||||
"""A positive-int `max_results`, else None. Rejects bools, an `int` subclass, and
|
||||
non-positive values so neither the request-side `count` nor the response-side cap
|
||||
forwards a value the other would silently ignore.
|
||||
"""
|
||||
if isinstance(max_results, bool) or not isinstance(max_results, int):
|
||||
return None
|
||||
return max_results if max_results > 0 else None
|
||||
|
||||
|
||||
def _requested_max_results(response_kwargs: Mapping[str, object]) -> int | None:
|
||||
"""The unified `max_results` cap the caller asked for, if any.
|
||||
|
||||
The built-in web_search tool has no server-side result-count knob, so the cap is
|
||||
enforced here after the fact; connection mode also honors it as a hard ceiling on
|
||||
top of the tool's `count` hint.
|
||||
"""
|
||||
optional_params: Final = response_kwargs.get("optional_params")
|
||||
if not isinstance(optional_params, Mapping):
|
||||
return None
|
||||
return _valid_max_results(optional_params.get("max_results"))
|
||||
|
||||
|
||||
def _capped(results: tuple[SearchResult, ...], max_results: int | None) -> tuple[SearchResult, ...]:
|
||||
return results[:max_results] if max_results is not None else results
|
||||
|
||||
|
||||
class _SearchConfiguration(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
project_connection_id: str
|
||||
count: int | None = None
|
||||
|
||||
|
||||
class _BingGroundingParams(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
search_configurations: tuple[_SearchConfiguration, ...]
|
||||
|
||||
|
||||
class _BingGroundingTool(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
type: Literal["bing_grounding"] = "bing_grounding"
|
||||
bing_grounding: _BingGroundingParams
|
||||
|
||||
|
||||
class _UserLocation(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
type: Literal["approximate"] = "approximate"
|
||||
country: str
|
||||
|
||||
|
||||
class _WebSearchTool(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
type: Literal["web_search"] = "web_search"
|
||||
user_location: _UserLocation | None = None
|
||||
|
||||
|
||||
class _ResponsesRequest(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
model: str
|
||||
input: str
|
||||
tools: tuple[_BingGroundingTool | _WebSearchTool, ...]
|
||||
|
||||
|
||||
def _search_tool(optional_params: Mapping[str, object]) -> _BingGroundingTool | _WebSearchTool:
|
||||
connection_id: Final = get_secret_str(CONNECTION_ID_ENV)
|
||||
max_results: Final = optional_params.get("max_results")
|
||||
country: Final = optional_params.get("country")
|
||||
if connection_id:
|
||||
configuration: Final = _SearchConfiguration(
|
||||
project_connection_id=connection_id,
|
||||
count=_valid_max_results(max_results),
|
||||
)
|
||||
return _BingGroundingTool(bing_grounding=_BingGroundingParams(search_configurations=(configuration,)))
|
||||
location: Final = _UserLocation(country=country.upper()) if isinstance(country, str) else None
|
||||
return _WebSearchTool(user_location=location)
|
||||
|
||||
|
||||
def _default_entra_token_minter() -> str:
|
||||
from litellm.secret_managers.get_azure_ad_token_provider import get_azure_ad_token_provider
|
||||
|
||||
return get_azure_ad_token_provider(azure_scope=ENTRA_SCOPE)()
|
||||
|
||||
|
||||
class BingGroundingSearchConfig(BaseSearchConfig):
|
||||
def __init__(self, entra_token_minter: Callable[[], str] | None = None) -> None:
|
||||
super().__init__()
|
||||
self._entra_token_minter = entra_token_minter
|
||||
|
||||
@staticmethod
|
||||
def ui_friendly_name() -> str:
|
||||
return "Grounding with Bing Search"
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict[str, str], # mutable-ok: BaseSearchConfig.validate_environment signature
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
**kwargs: object, # kwargs-ok: BaseSearchConfig.validate_environment signature
|
||||
) -> dict[str, str]: # mutable-ok: the http handler passes this straight to httpx as headers
|
||||
"""
|
||||
Validate environment and return headers.
|
||||
|
||||
Returns a new dict rather than mutating ``headers``: the http handler calls this
|
||||
a second time after ``litellm/search/main.py`` already did, so it has to be idempotent.
|
||||
"""
|
||||
return { # mutable-ok: httpx requires a plain dict of headers
|
||||
**headers,
|
||||
**self._auth_header(api_key, api_base),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _auth_header(self, api_key: str | None, api_base: str | None) -> Mapping[str, str]:
|
||||
"""
|
||||
A caller-supplied ``api_key`` is an Azure API key and rides the ``api-key`` header;
|
||||
an Entra bearer token (``BING_GROUNDING_TOKEN`` or one minted via azure-identity)
|
||||
rides ``Authorization: Bearer``. Foundry rejects the wrong scheme for each.
|
||||
"""
|
||||
if api_key:
|
||||
return MappingProxyType({"api-key": api_key})
|
||||
token: Final = self.resolve_server_api_key(
|
||||
caller_api_key=None,
|
||||
caller_api_base=api_base,
|
||||
key_env_vars=(TOKEN_ENV,),
|
||||
base_env_var=PROJECT_ENDPOINT_ENV,
|
||||
default_api_base=None,
|
||||
) or self._mint_entra_token(api_base)
|
||||
return MappingProxyType({"Authorization": f"Bearer {token}"})
|
||||
|
||||
def _mint_entra_token(self, caller_api_base: str | None) -> str:
|
||||
self._assert_trusted_api_base_for_server_credential(
|
||||
caller_api_base, None, PROJECT_ENDPOINT_ENV, "Azure AD token"
|
||||
)
|
||||
minter: Final = self._entra_token_minter or _default_entra_token_minter
|
||||
try:
|
||||
return minter()
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Grounding with Bing Search: no credential available. Pass api_key, set {TOKEN_ENV} "
|
||||
f"to an Entra bearer token, or configure azure-identity (AZURE_CLIENT_ID / "
|
||||
f"AZURE_CLIENT_SECRET / AZURE_TENANT_ID or any DefaultAzureCredential source) "
|
||||
f"for scope {ENTRA_SCOPE}. Underlying error: {e}"
|
||||
) from e
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
optional_params: dict[str, object], # mutable-ok: BaseSearchConfig.get_complete_url signature
|
||||
data: dict[str, object] | list[dict[str, object]] | None = None, # mutable-ok: base signature
|
||||
**kwargs: object, # kwargs-ok: BaseSearchConfig.get_complete_url signature
|
||||
) -> str:
|
||||
resolved_base: Final = api_base or get_secret_str(PROJECT_ENDPOINT_ENV)
|
||||
if not resolved_base:
|
||||
raise ValueError(
|
||||
f"{PROJECT_ENDPOINT_ENV} is not set. Set it to your Microsoft Foundry project "
|
||||
f"endpoint, e.g. https://<account>.services.ai.azure.com/api/projects/<project>."
|
||||
)
|
||||
trimmed: Final = resolved_base.rstrip("/")
|
||||
if trimmed.endswith(_RESPONSES_PATH):
|
||||
return trimmed
|
||||
return f"{trimmed}{_RESPONSES_PATH}"
|
||||
|
||||
def transform_search_request(
|
||||
self,
|
||||
query: str | list[str], # mutable-ok: BaseSearchConfig.transform_search_request signature
|
||||
optional_params: dict[str, object], # mutable-ok: base signature
|
||||
**kwargs: object, # kwargs-ok: BaseSearchConfig.transform_search_request signature
|
||||
) -> dict[str, object]: # mutable-ok: the http handler passes this straight to httpx as the JSON body
|
||||
"""
|
||||
Transform Search request to the Foundry Responses API format.
|
||||
|
||||
The unified params map as far as the API allows:
|
||||
- max_results -> the bing_grounding search configuration's `count`; the built-in
|
||||
web_search tool has no result-count knob, so that mode instead caps the returned
|
||||
results after the fact (see transform_search_response)
|
||||
- country -> web_search's approximate `user_location` (bing_grounding's `market`
|
||||
wants a full locale like en-US, which a bare country code cannot fill)
|
||||
- search_domain_filter, max_tokens_per_page -> no API equivalent, dropped
|
||||
"""
|
||||
model: Final = get_secret_str(MODEL_ENV)
|
||||
if not model:
|
||||
raise ValueError(
|
||||
f"{MODEL_ENV} is not set. Set it to a model deployment in the Foundry project "
|
||||
f"that runs the grounded search, e.g. gpt-4.1."
|
||||
)
|
||||
request: Final = _ResponsesRequest(
|
||||
model=model,
|
||||
input=" ".join(query) if isinstance(query, list) else query,
|
||||
tools=(_search_tool(optional_params),),
|
||||
)
|
||||
return request.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
def transform_search_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
**kwargs: object, # kwargs-ok: BaseSearchConfig.transform_search_response signature
|
||||
) -> SearchResponse:
|
||||
try:
|
||||
parsed: Final = _ResponsesEnvelope.model_validate_json(raw_response.content)
|
||||
except ValidationError as e:
|
||||
raise self.get_error_class(
|
||||
error_message=f"response does not match the Foundry Responses API schema: {e}",
|
||||
status_code=raw_response.status_code,
|
||||
headers=dict(raw_response.headers), # mutable-ok: BaseSearchConfig.get_error_class signature
|
||||
)
|
||||
if parsed.status == "failed":
|
||||
detail: Final = (
|
||||
parsed.error.message if parsed.error and parsed.error.message else "the grounded search failed"
|
||||
)
|
||||
raise self._upstream_error(detail, raw_response)
|
||||
results: Final = _capped(_citation_results(parsed), _requested_max_results(kwargs))
|
||||
if not results and parsed.status == "incomplete":
|
||||
reason: Final = (
|
||||
parsed.incomplete_details.reason
|
||||
if parsed.incomplete_details and parsed.incomplete_details.reason
|
||||
else "unknown reason"
|
||||
)
|
||||
raise self._upstream_error(f"the grounded search was incomplete: {reason}", raw_response)
|
||||
return self._priced(results)
|
||||
|
||||
def _upstream_error(self, detail: str, raw_response: httpx.Response) -> Exception:
|
||||
return self.get_error_class(
|
||||
error_message=detail,
|
||||
status_code=_UPSTREAM_ERROR_STATUS,
|
||||
headers=dict(raw_response.headers), # mutable-ok: BaseSearchConfig.get_error_class signature
|
||||
)
|
||||
|
||||
def _priced(self, results: tuple[SearchResult, ...]) -> SearchResponse:
|
||||
"""web_search mode runs no paid Grounding with Bing transaction, so it must not
|
||||
inherit the connection-mode ``bing_grounding/search`` price; zero its per-query
|
||||
cost while leaving connection mode to the cost map."""
|
||||
response: Final = SearchResponse(
|
||||
results=list(results), # mutable-ok: SearchResponse.results is list[SearchResult]
|
||||
object="search",
|
||||
)
|
||||
if get_secret_str(CONNECTION_ID_ENV):
|
||||
return response
|
||||
response._hidden_params[
|
||||
"additional_headers"
|
||||
] = { # mutable-ok: response_cost_calculator writes into _hidden_params
|
||||
_RESPONSE_COST_HEADER: 0.0
|
||||
}
|
||||
return response
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, str], # mutable-ok: BaseSearchConfig.get_error_class signature
|
||||
) -> Exception:
|
||||
detail: Final = _unwrap_error_detail(error_message).rstrip(". ")
|
||||
return BaseLLMException(
|
||||
status_code=status_code,
|
||||
message=f"Grounding with Bing Search: {detail}. See {_DOCS_URL} for details.",
|
||||
headers=headers,
|
||||
)
|
||||
|
|
@ -1847,6 +1847,7 @@ class BaseLLMHTTPHandler:
|
|||
return provider_config.transform_search_response(
|
||||
raw_response=response,
|
||||
logging_obj=logging_obj,
|
||||
optional_params=optional_params,
|
||||
)
|
||||
|
||||
async def async_search(
|
||||
|
|
@ -1945,6 +1946,7 @@ class BaseLLMHTTPHandler:
|
|||
return provider_config.transform_search_response(
|
||||
raw_response=response,
|
||||
logging_obj=logging_obj,
|
||||
optional_params=optional_params,
|
||||
)
|
||||
|
||||
async def _async_post_anthropic_messages_with_http_error_retry(
|
||||
|
|
|
|||
|
|
@ -17155,6 +17155,14 @@
|
|||
"notes": "Web Search on Amazon Bedrock AgentCore, billed by AWS on the gateway"
|
||||
}
|
||||
},
|
||||
"bing_grounding/search": {
|
||||
"input_cost_per_query": 0.035,
|
||||
"litellm_provider": "bing_grounding",
|
||||
"mode": "search",
|
||||
"metadata": {
|
||||
"notes": "Grounding with Bing Search (G1 SKU): $35 per 1,000 transactions. Tokens for the Foundry model deployment that runs the grounded search are billed separately on that deployment."
|
||||
}
|
||||
},
|
||||
"tinyfish/search": {
|
||||
"input_cost_per_query": 0.0,
|
||||
"litellm_provider": "tinyfish",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
# Web search via Microsoft Foundry (Grounding with Bing Search / the built-in
|
||||
# web_search tool), called through the Foundry Responses API.
|
||||
#
|
||||
# Configure the provider with env vars (setup and pricing are in the LiteLLM docs;
|
||||
# the code lives in litellm/llms/azure/search/transformation.py):
|
||||
# BING_GROUNDING_PROJECT_ENDPOINT (required) the Foundry project endpoint
|
||||
# BING_GROUNDING_MODEL (required) a model deployment in that project
|
||||
# BING_GROUNDING_CONNECTION_ID (optional) a Grounding with Bing connection id;
|
||||
# without it the built-in web_search tool is used
|
||||
# BING_GROUNDING_TOKEN (optional) an Entra bearer token; without it (and
|
||||
# without api_key) azure-identity mints one
|
||||
|
||||
model_list:
|
||||
- model_name: claude-sonnet
|
||||
litellm_params:
|
||||
model: bedrock/us.anthropic.claude-sonnet-5
|
||||
aws_region_name: us-east-1
|
||||
|
||||
search_tools:
|
||||
- search_tool_name: bing-grounding-search
|
||||
litellm_params:
|
||||
search_provider: bing_grounding
|
||||
# Optional: an Azure API key instead of BING_GROUNDING_TOKEN / azure-identity
|
||||
# api_key: os.environ/AZURE_AI_API_KEY
|
||||
|
||||
litellm_settings:
|
||||
callbacks: ["websearch_interception"]
|
||||
websearch_interception_params:
|
||||
enabled_providers: ["bedrock"]
|
||||
search_tool_name: bing-grounding-search
|
||||
|
|
@ -3855,6 +3855,7 @@ class SearchProviders(str, Enum):
|
|||
TINYFISH = "tinyfish"
|
||||
AGENTCORE = "agentcore"
|
||||
NIMBLE = "nimble"
|
||||
BING_GROUNDING = "bing_grounding"
|
||||
|
||||
|
||||
# Create a set of all search provider values for quick lookup
|
||||
|
|
|
|||
|
|
@ -9096,6 +9096,7 @@ class ProviderConfigManager:
|
|||
from litellm.llms.apiserpent.search.transformation import (
|
||||
APISerpentSearchConfig,
|
||||
)
|
||||
from litellm.llms.azure.search.transformation import BingGroundingSearchConfig
|
||||
from litellm.llms.bedrock.search.transformation import AgentCoreSearchConfig
|
||||
from litellm.llms.brave.search.transformation import BraveSearchConfig
|
||||
from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig
|
||||
|
|
@ -9137,6 +9138,7 @@ class ProviderConfigManager:
|
|||
SearchProviders.TINYFISH: TinyfishSearchConfig,
|
||||
SearchProviders.AGENTCORE: AgentCoreSearchConfig,
|
||||
SearchProviders.NIMBLE: NimbleSearchConfig,
|
||||
SearchProviders.BING_GROUNDING: BingGroundingSearchConfig,
|
||||
}
|
||||
config_class: Final = PROVIDER_TO_CONFIG_MAP.get(provider, None)
|
||||
if config_class is None:
|
||||
|
|
|
|||
|
|
@ -17155,6 +17155,14 @@
|
|||
"notes": "Web Search on Amazon Bedrock AgentCore, billed by AWS on the gateway"
|
||||
}
|
||||
},
|
||||
"bing_grounding/search": {
|
||||
"input_cost_per_query": 0.035,
|
||||
"litellm_provider": "bing_grounding",
|
||||
"mode": "search",
|
||||
"metadata": {
|
||||
"notes": "Grounding with Bing Search (G1 SKU): $35 per 1,000 transactions. Tokens for the Foundry model deployment that runs the grounded search are billed separately on that deployment."
|
||||
}
|
||||
},
|
||||
"tinyfish/search": {
|
||||
"input_cost_per_query": 0.0,
|
||||
"litellm_provider": "tinyfish",
|
||||
|
|
|
|||
199
tests/search_tests/test_bing_grounding_search.py
Normal file
199
tests/search_tests/test_bing_grounding_search.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"""
|
||||
Tests for the Grounding with Bing Search (Microsoft Foundry) integration.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from tests.search_tests.base_search_unit_tests import BaseSearchTest
|
||||
|
||||
PROJECT_ENDPOINT = "https://acct.services.ai.azure.com/api/projects/proj"
|
||||
|
||||
_ANSWER_TEXT = (
|
||||
"LiteLLM is an open source LLM gateway ([github.com](https://github.com/BerriAI/litellm))\n"
|
||||
"The docs live on docs.litellm.ai ([docs.litellm.ai](https://docs.litellm.ai/))"
|
||||
)
|
||||
|
||||
|
||||
def _annotation(marker: str, url: str, title: str) -> dict:
|
||||
start = _ANSWER_TEXT.index(marker)
|
||||
return {
|
||||
"type": "url_citation",
|
||||
"url": url,
|
||||
"title": title,
|
||||
"start_index": start,
|
||||
"end_index": start + len(marker),
|
||||
}
|
||||
|
||||
|
||||
MOCK_BING_GROUNDING_RESPONSE = {
|
||||
"id": "resp_mock",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-4.1",
|
||||
"output": [
|
||||
{"type": "web_search_call", "status": "completed"},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": _ANSWER_TEXT,
|
||||
"annotations": [
|
||||
_annotation(
|
||||
"([github.com](https://github.com/BerriAI/litellm))",
|
||||
"https://github.com/BerriAI/litellm",
|
||||
"BerriAI/litellm - GitHub",
|
||||
),
|
||||
_annotation(
|
||||
"([docs.litellm.ai](https://docs.litellm.ai/))",
|
||||
"https://docs.litellm.ai/",
|
||||
"LiteLLM Docs",
|
||||
),
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"usage": {"input_tokens": 100, "output_tokens": 50},
|
||||
}
|
||||
|
||||
|
||||
def _mock_response():
|
||||
response = Mock()
|
||||
response.status_code = 200
|
||||
response.headers = {}
|
||||
response.content = json.dumps(MOCK_BING_GROUNDING_RESPONSE).encode()
|
||||
return response
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Local only tested search providers")
|
||||
class TestBingGroundingSearch(BaseSearchTest):
|
||||
"""
|
||||
E2E tests for Grounding with Bing Search that make real API calls.
|
||||
Inherits from BaseSearchTest to run standard search tests.
|
||||
"""
|
||||
|
||||
def get_search_provider(self) -> str:
|
||||
return "bing_grounding"
|
||||
|
||||
|
||||
class TestBingGroundingSearchTransformation:
|
||||
"""
|
||||
Full-stack tests through `litellm.search` / `litellm.asearch` with the HTTP layer mocked.
|
||||
Transformation details are unit-tested in tests/test_litellm/llms/azure/search/.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _server_env(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_PROJECT_ENDPOINT", PROJECT_ENDPOINT)
|
||||
monkeypatch.setenv("BING_GROUNDING_MODEL", "gpt-4.1")
|
||||
monkeypatch.setenv("BING_GROUNDING_TOKEN", "test-entra-token")
|
||||
monkeypatch.delenv("BING_GROUNDING_CONNECTION_ID", raising=False)
|
||||
|
||||
def test_bing_grounding_search_request_and_response(self):
|
||||
with patch( # test-quality-ok: litellm.search has no client injection seam
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post",
|
||||
return_value=_mock_response(),
|
||||
) as mock_post:
|
||||
response = litellm.search(
|
||||
query="what is litellm",
|
||||
search_provider="bing_grounding",
|
||||
max_results=5,
|
||||
country="us",
|
||||
)
|
||||
|
||||
assert mock_post.called
|
||||
call_kwargs = mock_post.call_args.kwargs
|
||||
assert call_kwargs["url"] == f"{PROJECT_ENDPOINT}/openai/v1/responses"
|
||||
assert call_kwargs["headers"]["Authorization"] == "Bearer test-entra-token"
|
||||
|
||||
request_body = call_kwargs["json"]
|
||||
assert request_body["model"] == "gpt-4.1"
|
||||
assert request_body["input"] == "what is litellm"
|
||||
assert request_body["tools"] == [
|
||||
{"type": "web_search", "user_location": {"type": "approximate", "country": "US"}}
|
||||
]
|
||||
|
||||
assert response.object == "search"
|
||||
assert len(response.results) == 2
|
||||
assert response.results[0].url == "https://github.com/BerriAI/litellm"
|
||||
assert response.results[0].title == "BerriAI/litellm - GitHub"
|
||||
assert response.results[0].snippet == "LiteLLM is an open source LLM gateway"
|
||||
assert response.results[1].url == "https://docs.litellm.ai/"
|
||||
assert response.results[1].snippet == "The docs live on docs.litellm.ai"
|
||||
|
||||
def test_connection_mode_sends_the_bing_grounding_tool(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv(
|
||||
"BING_GROUNDING_CONNECTION_ID",
|
||||
"/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices"
|
||||
"/accounts/acct/projects/proj/connections/bing-conn",
|
||||
)
|
||||
with patch( # test-quality-ok: litellm.search has no client injection seam
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post",
|
||||
return_value=_mock_response(),
|
||||
) as mock_post:
|
||||
litellm.search(
|
||||
query="what is litellm",
|
||||
search_provider="bing_grounding",
|
||||
max_results=3,
|
||||
)
|
||||
|
||||
request_body = mock_post.call_args.kwargs["json"]
|
||||
assert request_body["tools"] == [
|
||||
{
|
||||
"type": "bing_grounding",
|
||||
"bing_grounding": {
|
||||
"search_configurations": [
|
||||
{
|
||||
"project_connection_id": (
|
||||
"/subscriptions/sub/resourceGroups/rg/providers/Microsoft.CognitiveServices"
|
||||
"/accounts/acct/projects/proj/connections/bing-conn"
|
||||
),
|
||||
"count": 3,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bing_grounding_asearch(self):
|
||||
with patch( # test-quality-ok: litellm.asearch has no client injection seam
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
new=AsyncMock(return_value=_mock_response()),
|
||||
) as mock_post:
|
||||
response = await litellm.asearch(
|
||||
query="what is litellm",
|
||||
search_provider="bing_grounding",
|
||||
)
|
||||
|
||||
assert mock_post.call_args.kwargs["json"]["tools"] == [{"type": "web_search"}]
|
||||
assert len(response.results) == 2
|
||||
|
||||
def test_web_search_mode_is_not_billed_the_g1_price(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
with patch( # test-quality-ok: litellm.search has no client injection seam
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post",
|
||||
return_value=_mock_response(),
|
||||
):
|
||||
response = litellm.search(query="pricing check", search_provider="bing_grounding")
|
||||
|
||||
assert response._hidden_params["response_cost"] == 0.0
|
||||
|
||||
def test_connection_mode_tracks_the_g1_cost(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_CONNECTION_ID", "conn-id")
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
with patch( # test-quality-ok: litellm.search has no client injection seam
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post",
|
||||
return_value=_mock_response(),
|
||||
):
|
||||
response = litellm.search(query="pricing check", search_provider="bing_grounding")
|
||||
|
||||
assert response._hidden_params["response_cost"] == pytest.approx(0.035)
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"id": "resp_04bcf916b629f9be006a8c7c053e9881959a4efe7118cb3a34",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-4.1",
|
||||
"output": [
|
||||
{
|
||||
"type": "web_search_call",
|
||||
"id": "ws_04bcf916b629f9be006a8c7c080e108195b3366ab048231129",
|
||||
"response_id": "resp_04bcf916b629f9be006a8c7c053e9881959a4efe7118cb3a34",
|
||||
"status": "completed",
|
||||
"action": {
|
||||
"type": "search",
|
||||
"queries": [
|
||||
"latest LiteLLM release GitHub LiteLLM",
|
||||
"LiteLLM github releases",
|
||||
"LiteLLM repo LiteLLM"
|
||||
],
|
||||
"query": "latest LiteLLM release GitHub"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_04bcf916b629f9be006a8c7c0c7b448195979bfafb3b28f1ba",
|
||||
"response_id": "resp_04bcf916b629f9be006a8c7c053e9881959a4efe7118cb3a34",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "You asked: “What is the latest LiteLLM release on GitHub? Answer with the version number.”\n\nI verified the most recent release information from the official GitHub repository for LiteLLM (BerriAI/litellm) using multiple sources, including GitHub itself and secondary trackers:\n\n- On the GitHub **Releases** page for BerriAI/litellm, the topmost entry is **v1.99.0‑rc.1**, marked as a pre‑release, published “yesterday” (relative to today, August 24, 2026) ([github.com](https://github.com/BerriAI/litellm/releases)). This indicates that version **v1.99.0‑rc.1** is the most recent tag available.\n- An external release‑tracking site (ReleaseAlert) confirms: **Latest version: v1.99.0‑rc.1**, last published August 22, 2026 ([releasealert.dev](https://releasealert.dev/github/BerriAI/litellm)).\n- The GitHub API (via `releases/latest`) currently points to **v1.98.0** as the latest **stable** release, with published date August 23, 2026 ([api.github.com](https://api.github.com/repos/BerriAI/litellm/releases/latest)).\n\nTo summarize:\n\n- The absolute **latest** release tag on GitHub is **v1.99.0‑rc.1** (release candidate), published recently (August 22, 2026) ([github.com](https://github.com/BerriAI/litellm/releases)).\n- The most recent **stable** release is **v1.98.0**, published August 23, 2026 ([api.github.com](https://api.github.com/repos/BerriAI/litellm/releases/latest)).\n\nSince you asked for the “latest LiteLLM release on GitHub,” without specifying stable vs. pre‑release, the correct answer is:\n\n**v1.99.0‑rc.1**\n\nLet me know if you'd like details on what's new in that release, or if you'd prefer the latest stable version.",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://github.com/BerriAI/litellm/releases",
|
||||
"start_index": 456,
|
||||
"end_index": 515,
|
||||
"title": "Releases · BerriAI/litellm - GitHub"
|
||||
},
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://releasealert.dev/github/BerriAI/litellm",
|
||||
"start_index": 722,
|
||||
"end_index": 791,
|
||||
"title": "BerriAI/litellm on GitHub | Release Alert"
|
||||
},
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://api.github.com/repos/BerriAI/litellm/releases/latest",
|
||||
"start_index": 936,
|
||||
"end_index": 1016,
|
||||
"title": "api.github.com"
|
||||
},
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://github.com/BerriAI/litellm/releases",
|
||||
"start_index": 1160,
|
||||
"end_index": 1219,
|
||||
"title": "Releases · BerriAI/litellm - GitHub"
|
||||
},
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://api.github.com/repos/BerriAI/litellm/releases/latest",
|
||||
"start_index": 1300,
|
||||
"end_index": 1380,
|
||||
"title": "api.github.com"
|
||||
}
|
||||
],
|
||||
"logprobs": []
|
||||
}
|
||||
],
|
||||
"status": "completed"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 15195,
|
||||
"output_tokens": 467
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.llms.azure.search.transformation import BingGroundingSearchConfig
|
||||
|
||||
REAL_FIXTURE = json.loads((Path(__file__).parent / "foundry_responses_web_search_fixture.json").read_text())
|
||||
|
||||
RESPONSES_URL = "https://acct.services.ai.azure.com/api/projects/proj/openai/v1/responses"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch: pytest.MonkeyPatch):
|
||||
for var in (
|
||||
"BING_GROUNDING_PROJECT_ENDPOINT",
|
||||
"BING_GROUNDING_MODEL",
|
||||
"BING_GROUNDING_CONNECTION_ID",
|
||||
"BING_GROUNDING_TOKEN",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
def _config(entra_token_minter=None) -> BingGroundingSearchConfig:
|
||||
return BingGroundingSearchConfig(entra_token_minter=entra_token_minter)
|
||||
|
||||
|
||||
def _resp(payload, status_code: int = 200):
|
||||
r = Mock()
|
||||
r.status_code = status_code
|
||||
r.headers = {}
|
||||
r.content = (payload if isinstance(payload, str) else json.dumps(payload)).encode()
|
||||
return r
|
||||
|
||||
|
||||
def _message_response(text: str, annotations: list) -> dict:
|
||||
return {
|
||||
"output": [
|
||||
{"type": "web_search_call", "status": "completed"},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": text, "annotations": annotations}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _citation(url: str, title: str, start: int, end: int) -> dict:
|
||||
return {"type": "url_citation", "url": url, "title": title, "start_index": start, "end_index": end}
|
||||
|
||||
|
||||
def test_ui_friendly_name():
|
||||
assert _config().ui_friendly_name() == "Grounding with Bing Search"
|
||||
|
||||
|
||||
def test_validate_environment_api_key_uses_api_key_header_not_bearer():
|
||||
headers = _config().validate_environment({}, api_key="azure-api-key")
|
||||
assert headers["api-key"] == "azure-api-key"
|
||||
assert "Authorization" not in headers
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
def test_validate_environment_reads_env_token(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_TOKEN", "env-token")
|
||||
headers = _config().validate_environment({})
|
||||
assert headers["Authorization"] == "Bearer env-token"
|
||||
assert "api-key" not in headers
|
||||
|
||||
|
||||
def test_validate_environment_falls_back_to_entra_minter():
|
||||
headers = _config(entra_token_minter=lambda: "entra-token").validate_environment({})
|
||||
assert headers["Authorization"] == "Bearer entra-token"
|
||||
|
||||
|
||||
def test_validate_environment_api_key_beats_env_token(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_TOKEN", "env-token")
|
||||
minter = Mock(return_value="entra-token")
|
||||
headers = _config(entra_token_minter=minter).validate_environment({}, api_key="azure-api-key")
|
||||
assert headers["api-key"] == "azure-api-key"
|
||||
assert "Authorization" not in headers
|
||||
minter.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_environment_env_token_beats_entra_minter(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_TOKEN", "env-token")
|
||||
minter = Mock(return_value="entra-token")
|
||||
assert _config(entra_token_minter=minter).validate_environment({})["Authorization"] == "Bearer env-token"
|
||||
minter.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_environment_refuses_entra_token_for_caller_api_base():
|
||||
minter = Mock(return_value="entra-token")
|
||||
with pytest.raises(ValueError, match="Refusing to send the server-configured"):
|
||||
_config(entra_token_minter=minter).validate_environment({}, api_base="https://attacker.example.com")
|
||||
minter.assert_not_called()
|
||||
|
||||
|
||||
def test_validate_environment_entra_minter_failure_names_the_options():
|
||||
def failing_minter() -> str:
|
||||
raise RuntimeError("no az login")
|
||||
|
||||
with pytest.raises(ValueError, match="no credential available") as excinfo:
|
||||
_config(entra_token_minter=failing_minter).validate_environment({})
|
||||
message = str(excinfo.value)
|
||||
assert "BING_GROUNDING_TOKEN" in message
|
||||
assert "https://ai.azure.com/.default" in message
|
||||
assert "no az login" in message
|
||||
|
||||
|
||||
def test_validate_environment_does_not_mutate_and_is_idempotent():
|
||||
config = _config()
|
||||
caller_headers = {"X-Custom": "keep-me"}
|
||||
|
||||
once = config.validate_environment(caller_headers, api_key="k")
|
||||
twice = config.validate_environment(once, api_key="k")
|
||||
|
||||
assert caller_headers == {"X-Custom": "keep-me"}
|
||||
assert once == twice
|
||||
assert once["X-Custom"] == "keep-me"
|
||||
|
||||
|
||||
def test_get_complete_url_from_api_base():
|
||||
url = _config().get_complete_url("https://acct.services.ai.azure.com/api/projects/proj", {})
|
||||
assert url == RESPONSES_URL
|
||||
|
||||
|
||||
def test_get_complete_url_reads_env_endpoint(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_PROJECT_ENDPOINT", "https://acct.services.ai.azure.com/api/projects/proj/")
|
||||
assert _config().get_complete_url(None, {}) == RESPONSES_URL
|
||||
|
||||
|
||||
def test_get_complete_url_missing_endpoint_raises():
|
||||
with pytest.raises(ValueError, match="BING_GROUNDING_PROJECT_ENDPOINT"):
|
||||
_config().get_complete_url(None, {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_base",
|
||||
[
|
||||
"https://acct.services.ai.azure.com/api/projects/proj",
|
||||
"https://acct.services.ai.azure.com/api/projects/proj/",
|
||||
"https://acct.services.ai.azure.com/api/projects/proj/openai/v1/responses",
|
||||
"https://acct.services.ai.azure.com/api/projects/proj/openai/v1/responses/",
|
||||
],
|
||||
)
|
||||
def test_get_complete_url_appends_responses_path_exactly_once(api_base: str):
|
||||
assert _config().get_complete_url(api_base, {}) == RESPONSES_URL
|
||||
|
||||
|
||||
def test_transform_search_request_missing_model_raises():
|
||||
with pytest.raises(ValueError, match="BING_GROUNDING_MODEL"):
|
||||
_config().transform_search_request("q", {})
|
||||
|
||||
|
||||
def test_transform_search_request_web_search_mode_exact_body(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_MODEL", "gpt-4.1")
|
||||
body = _config().transform_search_request("latest AI developments", {"max_results": 5})
|
||||
assert body == {
|
||||
"model": "gpt-4.1",
|
||||
"input": "latest AI developments",
|
||||
"tools": [{"type": "web_search"}],
|
||||
}
|
||||
|
||||
|
||||
def test_transform_search_request_web_search_mode_maps_country(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_MODEL", "gpt-4.1")
|
||||
body = _config().transform_search_request("q", {"country": "us"})
|
||||
assert body["tools"] == [{"type": "web_search", "user_location": {"type": "approximate", "country": "US"}}]
|
||||
|
||||
|
||||
def test_transform_search_request_connection_mode_exact_body(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_MODEL", "gpt-4.1")
|
||||
monkeypatch.setenv("BING_GROUNDING_CONNECTION_ID", "conn-id")
|
||||
body = _config().transform_search_request("q", {"max_results": 5})
|
||||
assert body == {
|
||||
"model": "gpt-4.1",
|
||||
"input": "q",
|
||||
"tools": [
|
||||
{
|
||||
"type": "bing_grounding",
|
||||
"bing_grounding": {"search_configurations": [{"project_connection_id": "conn-id", "count": 5}]},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_transform_search_request_connection_mode_omits_count_without_max_results(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setenv("BING_GROUNDING_MODEL", "gpt-4.1")
|
||||
monkeypatch.setenv("BING_GROUNDING_CONNECTION_ID", "conn-id")
|
||||
body = _config().transform_search_request("q", {})
|
||||
assert body["tools"][0]["bing_grounding"]["search_configurations"] == [{"project_connection_id": "conn-id"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_results", [True, False, 0, -1])
|
||||
def test_transform_search_request_connection_mode_omits_count_for_invalid_max_results(
|
||||
monkeypatch: pytest.MonkeyPatch, max_results: object
|
||||
):
|
||||
monkeypatch.setenv("BING_GROUNDING_MODEL", "gpt-4.1")
|
||||
monkeypatch.setenv("BING_GROUNDING_CONNECTION_ID", "conn-id")
|
||||
body = _config().transform_search_request("q", {"max_results": max_results})
|
||||
assert body["tools"][0]["bing_grounding"]["search_configurations"] == [{"project_connection_id": "conn-id"}]
|
||||
|
||||
|
||||
def test_transform_search_response_ignores_invalid_max_results_cap():
|
||||
annotations = [_citation(f"https://example.com/{i}", f"T{i}", 0, 5) for i in range(3)]
|
||||
resp = _config().transform_search_response(
|
||||
_resp(_message_response("claim", annotations)), logging_obj=Mock(), optional_params={"max_results": True}
|
||||
)
|
||||
assert [r.url for r in resp.results] == [f"https://example.com/{i}" for i in range(3)]
|
||||
|
||||
|
||||
def test_transform_search_request_joins_list_query(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_MODEL", "gpt-4.1")
|
||||
assert _config().transform_search_request(["foo", "bar"], {})["input"] == "foo bar"
|
||||
|
||||
|
||||
def test_transform_search_response_real_fixture_dedupes_and_preserves_order():
|
||||
resp = _config().transform_search_response(_resp(REAL_FIXTURE), logging_obj=Mock())
|
||||
|
||||
assert resp.object == "search"
|
||||
assert [r.url for r in resp.results] == [
|
||||
"https://github.com/BerriAI/litellm/releases",
|
||||
"https://releasealert.dev/github/BerriAI/litellm",
|
||||
"https://api.github.com/repos/BerriAI/litellm/releases/latest",
|
||||
]
|
||||
assert resp.results[0].title == "Releases · BerriAI/litellm - GitHub"
|
||||
assert resp.results[1].title == "BerriAI/litellm on GitHub | Release Alert"
|
||||
|
||||
|
||||
def test_transform_search_response_real_fixture_snippets_are_the_cited_claims():
|
||||
resp = _config().transform_search_response(_resp(REAL_FIXTURE), logging_obj=Mock())
|
||||
|
||||
assert resp.results[0].snippet.startswith("- On the GitHub **Releases** page for BerriAI/litellm")
|
||||
assert resp.results[1].snippet.startswith("- An external release")
|
||||
assert resp.results[2].snippet.startswith("- The GitHub API (via `releases/latest`)")
|
||||
for result in resp.results:
|
||||
assert "url_citation" not in result.snippet
|
||||
assert not result.snippet.startswith("([")
|
||||
|
||||
|
||||
def test_transform_search_response_snippet_falls_back_to_text_head_for_leading_citation():
|
||||
text = "([example.com](https://example.com)) trailing prose"
|
||||
payload = _message_response(text, [_citation("https://example.com", "Example", 0, 36)])
|
||||
resp = _config().transform_search_response(_resp(payload), logging_obj=Mock())
|
||||
assert resp.results[0].snippet == text
|
||||
|
||||
|
||||
def test_transform_search_response_snippet_without_indices_uses_last_line():
|
||||
payload = _message_response(
|
||||
"first line\nthe claim on the last line",
|
||||
[{"type": "url_citation", "url": "https://example.com", "title": "Example"}],
|
||||
)
|
||||
resp = _config().transform_search_response(_resp(payload), logging_obj=Mock())
|
||||
assert resp.results[0].snippet == "the claim on the last line"
|
||||
|
||||
|
||||
def test_transform_search_response_ignores_non_citation_annotations():
|
||||
payload = _message_response("text", [{"type": "file_citation", "url": "https://example.com"}])
|
||||
assert _config().transform_search_response(_resp(payload), logging_obj=Mock()).results == []
|
||||
|
||||
|
||||
def test_transform_search_response_ignores_citation_without_url():
|
||||
payload = _message_response("text", [{"type": "url_citation", "title": "no url"}])
|
||||
assert _config().transform_search_response(_resp(payload), logging_obj=Mock()).results == []
|
||||
|
||||
|
||||
def test_transform_search_response_no_message_output():
|
||||
payload = {"output": [{"type": "web_search_call", "status": "completed"}]}
|
||||
assert _config().transform_search_response(_resp(payload), logging_obj=Mock()).results == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
"<html>502 Bad Gateway</html>",
|
||||
'{"output": "garbage"}',
|
||||
'{"output": null}',
|
||||
"{}",
|
||||
],
|
||||
)
|
||||
def test_transform_search_response_malformed_body_raises_instead_of_reporting_empty(body: str):
|
||||
with pytest.raises(Exception, match="Grounding with Bing Search"):
|
||||
_config().transform_search_response(_resp(body, status_code=502), logging_obj=Mock())
|
||||
|
||||
|
||||
def test_transform_search_response_caps_results_to_max_results():
|
||||
annotations = [_citation(f"https://example.com/{i}", f"T{i}", 0, 5) for i in range(5)]
|
||||
resp = _config().transform_search_response(
|
||||
_resp(_message_response("claim", annotations)), logging_obj=Mock(), optional_params={"max_results": 2}
|
||||
)
|
||||
assert [r.url for r in resp.results] == ["https://example.com/0", "https://example.com/1"]
|
||||
|
||||
|
||||
def test_transform_search_response_without_max_results_returns_all_citations():
|
||||
annotations = [_citation(f"https://example.com/{i}", f"T{i}", 0, 5) for i in range(4)]
|
||||
resp = _config().transform_search_response(_resp(_message_response("c", annotations)), logging_obj=Mock())
|
||||
assert len(resp.results) == 4
|
||||
|
||||
|
||||
def test_transform_search_response_failed_status_raises_with_error_message():
|
||||
payload = {"output": [], "status": "failed", "error": {"message": "content was filtered"}}
|
||||
with pytest.raises(Exception, match="content was filtered") as excinfo:
|
||||
_config().transform_search_response(_resp(payload), logging_obj=Mock())
|
||||
assert excinfo.value.status_code == 502
|
||||
|
||||
|
||||
def test_transform_search_response_incomplete_with_no_results_raises_with_reason():
|
||||
payload = {"output": [], "status": "incomplete", "incomplete_details": {"reason": "max_output_tokens"}}
|
||||
with pytest.raises(Exception, match="incomplete: max_output_tokens"):
|
||||
_config().transform_search_response(_resp(payload), logging_obj=Mock())
|
||||
|
||||
|
||||
def test_transform_search_response_incomplete_with_partial_results_returns_them():
|
||||
payload = _message_response("claim", [_citation("https://example.com", "Example", 0, 5)])
|
||||
payload["status"] = "incomplete"
|
||||
resp = _config().transform_search_response(_resp(payload), logging_obj=Mock())
|
||||
assert [r.url for r in resp.results] == ["https://example.com"]
|
||||
|
||||
|
||||
def test_transform_search_response_web_search_mode_zeroes_per_query_cost():
|
||||
payload = _message_response("claim", [_citation("https://example.com", "Example", 0, 5)])
|
||||
resp = _config().transform_search_response(_resp(payload), logging_obj=Mock())
|
||||
assert resp._hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] == 0.0
|
||||
|
||||
|
||||
def test_transform_search_response_connection_mode_leaves_price_to_cost_map(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("BING_GROUNDING_CONNECTION_ID", "conn-id")
|
||||
payload = _message_response("claim", [_citation("https://example.com", "Example", 0, 5)])
|
||||
resp = _config().transform_search_response(_resp(payload), logging_obj=Mock())
|
||||
assert "additional_headers" not in resp._hidden_params
|
||||
|
||||
|
||||
def test_get_error_class_attributes_the_provider():
|
||||
error = _config().get_error_class(error_message="quota exceeded", status_code=429, headers={})
|
||||
assert error.status_code == 429
|
||||
assert "Grounding with Bing Search: quota exceeded" in str(error)
|
||||
assert "learn.microsoft.com" in str(error)
|
||||
|
||||
|
||||
def test_get_error_class_unwraps_the_nested_tool_error():
|
||||
nested_tool_error = json.dumps(
|
||||
{
|
||||
"error": "Tool_User_Error",
|
||||
"message": (
|
||||
"The specified connection ID 'conn-id' in tool config input was not found "
|
||||
"in the project or account connections."
|
||||
),
|
||||
"code": "invalid_tool_input",
|
||||
"tool": "bing_grounding",
|
||||
}
|
||||
)
|
||||
live_400_shape = json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": nested_tool_error,
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "tool_user_error",
|
||||
}
|
||||
}
|
||||
)
|
||||
error = _config().get_error_class(error_message=live_400_shape, status_code=400, headers={})
|
||||
assert (
|
||||
"Grounding with Bing Search: The specified connection ID 'conn-id' in tool config input "
|
||||
"was not found in the project or account connections" in str(error)
|
||||
)
|
||||
assert "Tool_User_Error" not in str(error)
|
||||
|
||||
|
||||
def test_get_error_class_unwraps_a_plain_error_envelope():
|
||||
error = _config().get_error_class(
|
||||
error_message='{"error":{"message":"The api key is invalid.","code":"401"}}',
|
||||
status_code=401,
|
||||
headers={},
|
||||
)
|
||||
assert "Grounding with Bing Search: The api key is invalid" in str(error)
|
||||
|
|
@ -16,6 +16,7 @@ import pytest
|
|||
|
||||
import litellm
|
||||
from litellm.llms.apiserpent.search.transformation import APISerpentSearchConfig
|
||||
from litellm.llms.azure.search.transformation import BingGroundingSearchConfig
|
||||
from litellm.llms.base_llm.search.transformation import (
|
||||
BaseSearchConfig,
|
||||
_is_trusted_search_api_base,
|
||||
|
|
@ -59,6 +60,7 @@ _BASE_ENV_VARS = (
|
|||
"TINYFISH_API_BASE",
|
||||
"CRW_API_BASE",
|
||||
"NIMBLE_API_BASE",
|
||||
"BING_GROUNDING_PROJECT_ENDPOINT",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -99,6 +101,7 @@ PROVIDERS: Tuple[ProviderSpec, ...] = (
|
|||
(TinyfishSearchConfig, {"TINYFISH_API_KEY": "srv"}, "caller-key", {}),
|
||||
(FastCRWSearchConfig, {"CRW_API_KEY": "srv"}, "caller-key", {}),
|
||||
(NimbleSearchConfig, {"NIMBLE_API_KEY": "srv"}, "caller-key", {}),
|
||||
(BingGroundingSearchConfig, {"BING_GROUNDING_TOKEN": "srv"}, "caller-key", {}),
|
||||
)
|
||||
|
||||
_IDS = tuple(spec[0].__name__ for spec in PROVIDERS)
|
||||
|
|
|
|||
BIN
ui/litellm-dashboard/public/assets/logos/bing.png
Normal file
BIN
ui/litellm-dashboard/public/assets/logos/bing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
|
|
@ -27,6 +27,7 @@ import { useZodForm } from "@/lib/forms/useZodForm";
|
|||
import SearchConnectionTest from "./SearchConnectionTest";
|
||||
import { buildSearchToolPayload } from "./searchToolPayload";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
import bingLogo from "../../../../../public/assets/logos/bing.png";
|
||||
import dataforseoLogo from "../../../../../public/assets/logos/dataforseo.png";
|
||||
import exaAiLogo from "../../../../../public/assets/logos/exa_ai.png";
|
||||
import googlePseLogo from "../../../../../public/assets/logos/google_pse.png";
|
||||
|
|
@ -44,6 +45,7 @@ const searchProviderLogoMap: Record<string, string> = {
|
|||
google_pse: googlePseLogo.src,
|
||||
dataforseo: dataforseoLogo.src,
|
||||
nimble: nimbleLogo.src,
|
||||
bing_grounding: bingLogo.src,
|
||||
};
|
||||
|
||||
interface SearchProviderLabelProps {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue