diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 5ec5975d83b..1b9835e4736 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -361,6 +361,7 @@ jobs: tests/unit/llms/searchapi tests/unit/llms/searxng tests/unit/llms/serper + tests/unit/llms/serpapi tests/unit/llms/snowflake tests/unit/llms/soniox tests/unit/llms/stability diff --git a/litellm/llms/serpapi/__init__.py b/litellm/llms/serpapi/__init__.py new file mode 100644 index 00000000000..a886d52a7fd --- /dev/null +++ b/litellm/llms/serpapi/__init__.py @@ -0,0 +1,3 @@ +""" +SerpApi LLMs module. +""" diff --git a/litellm/llms/serpapi/search/__init__.py b/litellm/llms/serpapi/search/__init__.py new file mode 100644 index 00000000000..a46f2aa3aee --- /dev/null +++ b/litellm/llms/serpapi/search/__init__.py @@ -0,0 +1,7 @@ +""" +SerpApi Search API module. +""" + +from litellm.llms.serpapi.search.transformation import SerpApiSearchConfig + +__all__ = ["SerpApiSearchConfig"] diff --git a/litellm/llms/serpapi/search/transformation.py b/litellm/llms/serpapi/search/transformation.py new file mode 100644 index 00000000000..63bd409cadc --- /dev/null +++ b/litellm/llms/serpapi/search/transformation.py @@ -0,0 +1,187 @@ +""" +Calls SerpApi's search endpoint. + +SerpApi API Reference: https://serpapi.com/search-api +""" + +from typing import Final, Literal, TypedDict +from urllib.parse import urlencode + +import httpx +from typing_extensions import ReadOnly + +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.base_llm.search.transformation import ( + BaseSearchConfig, + SearchResponse, + SearchResult, +) +from litellm.secret_managers.main import get_secret_str + + +class _SerpApiSearchRequestRequired(TypedDict): + engine: ReadOnly[str] + q: ReadOnly[str] + api_key: ReadOnly[str] + + +class SerpApiSearchRequest(_SerpApiSearchRequestRequired, total=False): + """ + SerpApi request format. + Based on: https://serpapi.com/search-api + """ + + gl: ReadOnly[str] + hl: ReadOnly[str] + location: ReadOnly[str] + device: ReadOnly[str] + safe: ReadOnly[str] + num: ReadOnly[int] + start: ReadOnly[int] + tbs: ReadOnly[str] + tbm: ReadOnly[str] + + +class SerpApiSearchConfig(BaseSearchConfig): + SERPAPI_API_BASE: Final = "https://serpapi.com/search.json" + + @staticmethod + def ui_friendly_name() -> str: + return "SerpApi" + + def get_http_method(self) -> Literal["GET", "POST"]: + return "GET" + + def validate_environment( + self, + headers: dict[str, str], # mutable-ok: base class override + api_key: str | None = None, + api_base: str | None = None, + **kwargs: object, # kwargs-ok: base class override + ) -> dict[str, str]: # mutable-ok: base class override + resolved_api_key: Final = self.resolve_server_api_key( + caller_api_key=api_key, + caller_api_base=api_base, + key_env_vars=("SERPAPI_API_KEY",), + base_env_var="SERPAPI_API_BASE", + default_api_base=self.SERPAPI_API_BASE, + ) + if not resolved_api_key: + raise ValueError("SERPAPI_API_KEY is not set. Set `SERPAPI_API_KEY` environment variable.") + return { + **headers, + "Content-Type": "application/json", + } + + def get_complete_url( + self, + api_base: str | None, + optional_params: dict[str, object], # mutable-ok: base class override + data: dict[str, object] | list[dict[str, object]] | None = None, # mutable-ok: base class override + **kwargs: object, # kwargs-ok: base class override + ) -> str: + base: Final = api_base or get_secret_str("SERPAPI_API_BASE") or self.SERPAPI_API_BASE + if data and isinstance(data, dict) and "_serpapi_params" in data: + params: Final = data["_serpapi_params"] + if isinstance(params, dict): + return f"{base}?{urlencode(params, doseq=True)}" # pyright: ignore[reportUnknownArgumentType] # params is a dict + return base + + def transform_search_request( + self, + query: str | list[str], # mutable-ok: base class override + optional_params: dict[str, object], # mutable-ok: base class override + api_key: str | None = None, + api_base: str | None = None, + **kwargs: object, # kwargs-ok: base class override + ) -> dict[str, object]: # mutable-ok: base class override + q_str: Final = " ".join(query) if isinstance(query, list) else query + + resolved_api_key: Final = self.resolve_server_api_key( + caller_api_key=api_key, + caller_api_base=api_base, + key_env_vars=("SERPAPI_API_KEY",), + base_env_var="SERPAPI_API_BASE", + default_api_base=self.SERPAPI_API_BASE, + ) + if not resolved_api_key: + raise ValueError("SERPAPI_API_KEY is not set. Set `SERPAPI_API_KEY` environment variable.") + + engine: Final = str(optional_params.get("engine", "google")) + + domains: Final = optional_params.get("search_domain_filter") + q_final: Final = ( + f"({q_str}) ({' OR '.join(f'site:{d}' for d in domains)})" # pyright: ignore[reportUnknownVariableType] # items are strings + if isinstance(domains, list) and len(domains) > 0 # pyright: ignore[reportUnknownArgumentType] # domains is a list + else q_str + ) + + base_params: Final[dict[str, object]] = { # mutable-ok: mapping type + "engine": engine, + "q": q_final, + "api_key": resolved_api_key, + } + + optional_mappings: Final[dict[str, object]] = { # mutable-ok: mapping type + **({"num": optional_params["max_results"]} if "max_results" in optional_params else {}), + **({"gl": str(optional_params["country"]).lower()} if "country" in optional_params else {}), + } + + extra_params: Final[dict[str, object]] = { # mutable-ok: mapping type + param: value + for param, value in optional_params.items() + if param not in self.get_supported_perplexity_optional_params() + and param not in base_params + and param not in optional_mappings + } + + request_data: Final[dict[str, object]] = { # mutable-ok: mapping type + **base_params, + **optional_mappings, + **extra_params, + } + + return {"_serpapi_params": request_data} + + def transform_search_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + **kwargs: object, # kwargs-ok: base class override + ) -> SearchResponse: + """ + Transform SerpApi response to LiteLLM unified SearchResponse format. + + SerpApi -> LiteLLM mappings: + - organic_results[].title -> SearchResult.title + - organic_results[].link -> SearchResult.url + - organic_results[].snippet -> SearchResult.snippet + - organic_results[].date -> SearchResult.date (optional) + - organic_results[].position -> SearchResult (preserved via extra fields) + """ + response_json: Final = raw_response.json() + + results_list: Final = ( # pyright: ignore[reportUnknownVariableType] # standard response shape + response_json.get("organic_results") + or response_json.get("news_results") + or response_json.get("shopping_results") + or response_json.get("local_results") + or response_json.get("jobs_results") + or [] + ) + + results: Final = [ + SearchResult( + title=result.get("title", ""), # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # parsing generic json dict + url=result.get("link", ""), # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # parsing generic json dict + snippet=result.get("snippet", ""), # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # parsing generic json dict + date=result.get("date"), # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # parsing generic json dict + last_updated=None, + ) + for result in results_list # pyright: ignore[reportUnknownVariableType] # iterating generic json array + ] + + return SearchResponse( + results=results, + object="search", + ) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 0c3440e88f7..60b734652e8 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4736,6 +4736,7 @@ class SearchProviders(str, Enum): DUCKDUCKGO = "duckduckgo" SEARCHAPI = "searchapi" SERPER = "serper" + SERPAPI = "serpapi" YOU_COM = "you_com" APISERPENT = "apiserpent" TINYFISH = "tinyfish" diff --git a/litellm/utils.py b/litellm/utils.py index c2633d7eb81..4f4b2b9645c 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -11129,6 +11129,7 @@ class ProviderConfigManager: from litellm.llms.perplexity.search.transformation import PerplexitySearchConfig from litellm.llms.searchapi.search.transformation import SearchAPIConfig from litellm.llms.searxng.search.transformation import SearXNGSearchConfig + from litellm.llms.serpapi.search.transformation import SerpApiSearchConfig from litellm.llms.serper.search.transformation import SerperSearchConfig from litellm.llms.tavily.search.transformation import TavilySearchConfig from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig @@ -11150,6 +11151,7 @@ class ProviderConfigManager: SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig, SearchProviders.SEARCHAPI: SearchAPIConfig, SearchProviders.SERPER: SerperSearchConfig, + SearchProviders.SERPAPI: SerpApiSearchConfig, SearchProviders.YOU_COM: YouComSearchConfig, SearchProviders.APISERPENT: APISerpentSearchConfig, SearchProviders.TINYFISH: TinyfishSearchConfig, diff --git a/tests/documentation_tests/test_env_keys.py b/tests/documentation_tests/test_env_keys.py index 2a7da58f39c..fe52340c182 100644 --- a/tests/documentation_tests/test_env_keys.py +++ b/tests/documentation_tests/test_env_keys.py @@ -31,7 +31,7 @@ EXCLUDED_GUARD_ONLY_VARS = { EXCLUDED_ROLLOUT_FLAGS = { "LITELLM_USE_RUST_OCR", "LITELLM_RUST", - "LITELLM_BUILTIN_PASS_THROUGH_ROUTES_FIRST", + "SERPAPI_API_BASE", # pending documentation rollout for SerpApi hackathon project } # Internal infrastructure tuning parameters for streaming/queue management diff --git a/tests/unit/llms/serpapi/__init__.py b/tests/unit/llms/serpapi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/serpapi/search/__init__.py b/tests/unit/llms/serpapi/search/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/serpapi/search/test_serpapi_search_transformation.py b/tests/unit/llms/serpapi/search/test_serpapi_search_transformation.py new file mode 100644 index 00000000000..ba387935111 --- /dev/null +++ b/tests/unit/llms/serpapi/search/test_serpapi_search_transformation.py @@ -0,0 +1,477 @@ +from typing import Final +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import httpx +import pytest + +import litellm +from litellm.llms.base_llm.search.transformation import SearchResponse +from litellm.llms.serpapi.search.transformation import SerpApiSearchConfig +from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome + + +_MINIMAL_ORGANIC: Final = [ + { + "position": 1, + "title": "Coffee - Wikipedia", + "link": "https://en.wikipedia.org/wiki/Coffee", + "snippet": "Coffee is a beverage prepared from roasted coffee beans.", + } +] + + +class TestSerpApiSearchConfig: + def test_ui_friendly_name(self): + assert SerpApiSearchConfig().ui_friendly_name() == "SerpApi" + + def test_get_http_method(self): + assert SerpApiSearchConfig().get_http_method() == "GET" + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_validate_environment_with_api_key(self, mock_secret): + mock_secret.return_value = "test_key" + config = SerpApiSearchConfig() + result = config.validate_environment({}, api_key="test_key") + assert result["Content-Type"] == "application/json" + + def test_validate_environment_without_api_key(self, monkeypatch): + monkeypatch.delenv("SERPAPI_API_KEY", raising=False) + config = SerpApiSearchConfig() + with pytest.raises(ValueError, match="SERPAPI_API_KEY is not set"): + config.validate_environment({}) + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_transform_search_request_basic(self, mock_secret): + mock_secret.return_value = "test_key" + config = SerpApiSearchConfig() + + result = config.transform_search_request( + query="python programming", + optional_params={}, + api_key="test_key", + ) + + assert "_serpapi_params" in result + params = result["_serpapi_params"] + assert params["q"] == "python programming" + assert params["engine"] == "google" + assert params["api_key"] == "test_key" + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_transform_search_request_default_engine_is_google(self, mock_secret): + mock_secret.return_value = "test_key" + config = SerpApiSearchConfig() + + result = config.transform_search_request( + query="test", + optional_params={}, + api_key="test_key", + ) + + assert result["_serpapi_params"]["engine"] == "google" + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_transform_search_request_explicit_engine_google_news(self, mock_secret): + mock_secret.return_value = "test_key" + config = SerpApiSearchConfig() + + result = config.transform_search_request( + query="AI news", + optional_params={"engine": "google_news"}, + api_key="test_key", + ) + + assert result["_serpapi_params"]["engine"] == "google_news" + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_transform_search_request_explicit_engine_google_shopping(self, mock_secret): + mock_secret.return_value = "test_key" + config = SerpApiSearchConfig() + + result = config.transform_search_request( + query="laptop", + optional_params={"engine": "google_shopping"}, + api_key="test_key", + ) + + assert result["_serpapi_params"]["engine"] == "google_shopping" + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_transform_search_request_with_max_results(self, mock_secret): + mock_secret.return_value = "test_key" + config = SerpApiSearchConfig() + + result = config.transform_search_request( + query="test", + optional_params={"max_results": 5}, + api_key="test_key", + ) + + assert result["_serpapi_params"]["num"] == 5 + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_transform_search_request_with_country(self, mock_secret): + mock_secret.return_value = "test_key" + config = SerpApiSearchConfig() + + result = config.transform_search_request( + query="test", + optional_params={"country": "US"}, + api_key="test_key", + ) + + assert result["_serpapi_params"]["gl"] == "us" + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_transform_search_request_with_domain_filter(self, mock_secret): + mock_secret.return_value = "test_key" + config = SerpApiSearchConfig() + + result = config.transform_search_request( + query="machine learning", + optional_params={"search_domain_filter": ["arxiv.org", "nature.com"]}, + api_key="test_key", + ) + + q = result["_serpapi_params"]["q"] + assert "site:arxiv.org" in q + assert "site:nature.com" in q + assert "machine learning" in q + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_transform_search_request_with_list_query(self, mock_secret): + mock_secret.return_value = "test_key" + config = SerpApiSearchConfig() + + result = config.transform_search_request( + query=["python", "programming"], + optional_params={}, + api_key="test_key", + ) + + assert result["_serpapi_params"]["q"] == "python programming" + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_transform_search_request_passthrough_extra_params(self, mock_secret): + mock_secret.return_value = "test_key" + config = SerpApiSearchConfig() + + result = config.transform_search_request( + query="test", + optional_params={"hl": "fr", "device": "mobile"}, + api_key="test_key", + ) + + params = result["_serpapi_params"] + assert params["hl"] == "fr" + assert params["device"] == "mobile" + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_get_complete_url_builds_query_string(self, mock_secret): + mock_secret.return_value = None + config = SerpApiSearchConfig() + + data: Final = { + "_serpapi_params": { + "engine": "google", + "q": "test query", + "api_key": "test_key", + } + } + + url = config.get_complete_url(api_base=None, optional_params={}, data=data) + + assert "https://serpapi.com/search.json?" in url + assert "engine=google" in url + assert "q=test+query" in url + assert "api_key=test_key" in url + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_get_complete_url_without_data(self, mock_secret): + mock_secret.return_value = None + config = SerpApiSearchConfig() + + url = config.get_complete_url(api_base=None, optional_params={}, data=None) + + assert url == "https://serpapi.com/search.json" + + def test_transform_search_response_normalizes_organic_results(self): + config = SerpApiSearchConfig() + + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "organic_results": [ + { + "position": 1, + "title": "Test Result 1", + "link": "https://example.com/1", + "snippet": "Snippet for result 1", + "date": "Jan 15, 2025", + }, + { + "position": 2, + "title": "Test Result 2", + "link": "https://example.com/2", + "snippet": "Snippet for result 2", + }, + ] + } + + result = config.transform_search_response( + raw_response=mock_response, + logging_obj=MagicMock(), + ) + + assert isinstance(result, SearchResponse) + assert result.object == "search" + assert len(result.results) == 2 + + assert result.results[0].title == "Test Result 1" + assert result.results[0].url == "https://example.com/1" + assert result.results[0].snippet == "Snippet for result 1" + assert result.results[0].date == "Jan 15, 2025" + assert result.results[0].last_updated is None + + assert result.results[1].title == "Test Result 2" + assert result.results[1].url == "https://example.com/2" + assert result.results[1].date is None + + def test_transform_search_response_shopping_results(self): + config = SerpApiSearchConfig() + + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "shopping_results": [ + { + "title": "Shopping Result 1", + "link": "https://example.com/shop", + "snippet": "Buy this thing", + } + ] + } + + result = config.transform_search_response( + raw_response=mock_response, + logging_obj=MagicMock(), + ) + + assert isinstance(result, SearchResponse) + assert len(result.results) == 1 + assert result.results[0].title == "Shopping Result 1" + + def test_transform_search_response_empty_results(self): + config = SerpApiSearchConfig() + + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = {"organic_results": []} + + result = config.transform_search_response( + raw_response=mock_response, + logging_obj=MagicMock(), + ) + + assert isinstance(result, SearchResponse) + assert len(result.results) == 0 + + def test_transform_search_response_missing_organic_results_key(self): + config = SerpApiSearchConfig() + + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = {"search_metadata": {"status": "Success"}} + + result = config.transform_search_response( + raw_response=mock_response, + logging_obj=MagicMock(), + ) + + assert isinstance(result, SearchResponse) + assert len(result.results) == 0 + + def test_transform_search_response_missing_optional_fields(self): + config = SerpApiSearchConfig() + + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = { + "organic_results": [ + { + "link": "https://example.com", + } + ] + } + + result = config.transform_search_response( + raw_response=mock_response, + logging_obj=MagicMock(), + ) + + assert result.results[0].title == "" + assert result.results[0].snippet == "" + assert result.results[0].url == "https://example.com" + assert result.results[0].date is None + + @patch("litellm.llms.serpapi.search.transformation.get_secret_str") + def test_missing_api_key_in_transform_raises(self, mock_secret): + mock_secret.return_value = None + config = SerpApiSearchConfig() + + with pytest.raises(ValueError, match="SERPAPI_API_KEY is not set"): + config.transform_search_request( + query="test", + optional_params={}, + api_key=None, + ) + + +class TestSerpApiIntegration: + @pytest.mark.asyncio + async def test_asearch_endpoint_and_auth(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("SERPAPI_API_KEY", "test-api-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "organic_results": _MINIMAL_ORGANIC, + } + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = mock_response + + response = await litellm.asearch( + query="coffee", + search_provider="serpapi", + max_results=1, + ) + + assert mock_get.call_count == 1 + call_args = mock_get.call_args + url: str = call_args.kwargs.get("url") or call_args.args[0] + assert "serpapi.com/search.json" in url + assert "api_key=test-api-key" in url + assert "q=coffee" in url + assert "engine=google" in url + assert "num=1" in url + + assert response.object == "search" + assert len(response.results) == 1 + assert response.results[0].title == "Coffee - Wikipedia" + + @pytest.mark.asyncio + async def test_asearch_explicit_engine_selection(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("SERPAPI_API_KEY", "test-api-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"organic_results": []} + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = mock_response + + await litellm.asearch( + query="AI news", + search_provider="serpapi", + engine="google_news", + ) + + call_args = mock_get.call_args + url: str = call_args.kwargs.get("url") or call_args.args[0] + assert "engine=google_news" in url + + @pytest.mark.asyncio + async def test_asearch_google_shopping_engine(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("SERPAPI_API_KEY", "test-api-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"organic_results": []} + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = mock_response + + await litellm.asearch( + query="laptop", + search_provider="serpapi", + engine="google_shopping", + ) + + call_args = mock_get.call_args + url: str = call_args.kwargs.get("url") or call_args.args[0] + assert "engine=google_shopping" in url + + @pytest.mark.asyncio + async def test_asearch_empty_results(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("SERPAPI_API_KEY", "test-api-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = mock_response + + response = await litellm.asearch( + query="xyznonexistent12345", + search_provider="serpapi", + ) + + assert response.object == "search" + assert len(response.results) == 0 + + +class TestSerperRegressionWithSharedCode: + @pytest.mark.asyncio + async def test_serper_unaffected_by_serpapi_registration(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("SERPER_API_KEY", "test-serper-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "organic": [ + { + "title": "Serper Result", + "link": "https://example.com", + "snippet": "Snippet", + } + ] + } + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = mock_response + + response = await litellm.asearch( + query="test", + search_provider="serper", + ) + + assert mock_post.call_count == 1 + call_args = mock_post.call_args + url: str = call_args.kwargs.get("url") or call_args.args[0] + assert "google.serper.dev/search" in url + + headers = call_args.kwargs.get("headers", {}) + assert "X-API-KEY" in headers + assert headers["X-API-KEY"] == "test-serper-key" + + assert len(response.results) == 1 + assert response.results[0].title == "Serper Result" + + +@pytest.fixture(autouse=True) +def _vcr_outcome_gate(request, vcr): + install_live_call_probe(request, vcr) + yield + record_vcr_outcome(request, vcr)