diff --git a/litellm/llms/exa_ai/search/transformation.py b/litellm/llms/exa_ai/search/transformation.py index 022622d7af7..4c9f12cd07d 100644 --- a/litellm/llms/exa_ai/search/transformation.py +++ b/litellm/llms/exa_ai/search/transformation.py @@ -7,6 +7,7 @@ Exa AI API Reference: https://docs.exa.ai/reference/search from typing import Final, TypedDict 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 ( @@ -46,6 +47,42 @@ class ExaAISearchRequest(_ExaAISearchRequestRequired, total=False): contents: dict # Optional - content retrieval options +class ExaAISearchResult(TypedDict, total=False): + """ + A single entry of Exa AI's search response `results` array. + Based on: https://docs.exa.ai/reference/search + """ + + title: ReadOnly[str] + url: ReadOnly[str] + text: ReadOnly[str] + highlights: ReadOnly[list[str]] + summary: ReadOnly[str] + publishedDate: ReadOnly[str] + + +_HIGHLIGHT_SEPARATOR: Final[str] = "\n\n" + + +def _exa_snippet(result: ExaAISearchResult) -> str: + """ + Exa returns each requested content mode in its own field, and omits `text` + entirely when only `highlights` or `summary` were asked for. + """ + return ( + result.get("text") or _HIGHLIGHT_SEPARATOR.join(result.get("highlights") or ()) or result.get("summary") or "" + ) + + +def _exa_content_fields(result: ExaAISearchResult) -> dict[str, list[str] | str]: + highlights: Final = result.get("highlights") + summary: Final = result.get("summary") + return { + **({"highlights": highlights} if highlights is not None else {}), + **({"summary": summary} if summary is not None else {}), + } + + class ExaAISearchConfig(BaseSearchConfig): EXA_AI_API_BASE = "https://api.exa.ai" @@ -164,7 +201,8 @@ class ExaAISearchConfig(BaseSearchConfig): Exa AI → LiteLLM mappings: - results[].title → SearchResult.title - results[].url → SearchResult.url - - results[].text → SearchResult.snippet + - results[].text, else results[].highlights, else results[].summary → SearchResult.snippet + - results[].highlights, results[].summary → passed through when present - results[].publishedDate → SearchResult.date - No last_updated field in Exa AI response (set to None) @@ -177,17 +215,17 @@ class ExaAISearchConfig(BaseSearchConfig): """ response_json: Final = raw_response.json() - # Transform results to SearchResult objects - results: Final = [] - for result in response_json.get("results", []): - search_result = SearchResult( + results: Final = [ + SearchResult( title=result.get("title", ""), url=result.get("url", ""), - snippet=result.get("text", ""), # Exa AI uses "text" for content + snippet=_exa_snippet(result), date=result.get("publishedDate"), # ISO 8601 datetime string last_updated=None, # Exa AI doesn't provide last_updated in response + **_exa_content_fields(result), ) - results.append(search_result) + for result in response_json.get("results", []) + ] return SearchResponse( results=results, diff --git a/tests/test_litellm/llms/exa_ai/search/test_transformation.py b/tests/test_litellm/llms/exa_ai/search/test_transformation.py new file mode 100644 index 00000000000..11e57875193 --- /dev/null +++ b/tests/test_litellm/llms/exa_ai/search/test_transformation.py @@ -0,0 +1,118 @@ +""" +Tests for Exa AI search response transformation. + +Regression coverage for https://github.com/BerriAI/litellm/issues/36905: +Exa returns each requested content mode in its own response field and omits +`text` entirely when only `highlights` or `summary` were requested, so reading +only `text` silently dropped billed content and returned an empty snippet. +""" + +import json +from unittest.mock import MagicMock + +import httpx +import pytest + +from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig + + +def _raw_response(payload: dict) -> httpx.Response: + return httpx.Response( + status_code=200, + content=json.dumps(payload).encode(), + request=httpx.Request("POST", "https://api.exa.ai/search"), + ) + + +@pytest.fixture +def config() -> ExaAISearchConfig: + return ExaAISearchConfig() + + +class TestExaAISnippetFallback: + def test_text_is_used_when_present(self, config): + response = config.transform_search_response( + raw_response=_raw_response( + {"results": [{"title": "t", "url": "https://example.com", "text": "full page text"}]} + ), + logging_obj=MagicMock(), + ) + + assert response.results[0].snippet == "full page text" + + def test_highlights_fill_snippet_when_text_absent(self, config): + response = config.transform_search_response( + raw_response=_raw_response( + { + "results": [ + { + "title": "t", + "url": "https://example.com", + "highlights": ["first highlight", "second highlight"], + } + ] + } + ), + logging_obj=MagicMock(), + ) + + assert response.results[0].snippet == "first highlight\n\nsecond highlight" + assert response.results[0].highlights == ["first highlight", "second highlight"] + + def test_summary_fills_snippet_when_text_and_highlights_absent(self, config): + response = config.transform_search_response( + raw_response=_raw_response( + {"results": [{"title": "t", "url": "https://example.com", "summary": "a short summary"}]} + ), + logging_obj=MagicMock(), + ) + + assert response.results[0].snippet == "a short summary" + assert response.results[0].summary == "a short summary" + + def test_text_wins_over_highlights_and_summary(self, config): + response = config.transform_search_response( + raw_response=_raw_response( + { + "results": [ + { + "title": "t", + "url": "https://example.com", + "text": "full page text", + "highlights": ["a highlight"], + "summary": "a summary", + } + ] + } + ), + logging_obj=MagicMock(), + ) + + result = response.results[0] + assert result.snippet == "full page text" + assert result.highlights == ["a highlight"] + assert result.summary == "a summary" + + def test_empty_highlights_list_falls_through_to_summary(self, config): + response = config.transform_search_response( + raw_response=_raw_response( + {"results": [{"title": "t", "url": "https://example.com", "highlights": [], "summary": "a summary"}]} + ), + logging_obj=MagicMock(), + ) + + assert response.results[0].snippet == "a summary" + + def test_no_content_modes_yields_empty_snippet_and_no_extra_fields(self, config): + response = config.transform_search_response( + raw_response=_raw_response( + {"results": [{"title": "t", "url": "https://example.com", "publishedDate": "2026-08-14T00:00:00Z"}]} + ), + logging_obj=MagicMock(), + ) + + result = response.results[0] + assert result.snippet == "" + assert result.date == "2026-08-14T00:00:00Z" + assert not hasattr(result, "highlights") + assert not hasattr(result, "summary")