From e9a9750a5a28babb1ff8a545884d028b04aa912f Mon Sep 17 00:00:00 2001 From: Priyansh Nandwana Date: Sun, 16 Aug 2026 14:31:48 +0530 Subject: [PATCH] fix(exa_ai): keep highlights and summary in search results Exa returns each requested content mode in its own response field, and returns no `text` field at all when only `contents.highlights` or `contents.summary` were asked for. The response transformer read only `text`, so those searches came back with an empty snippet on every result while Exa still billed for the content retrieval. Snippet now falls back from text to highlights to summary, and the raw highlights and summary fields are passed through when present. --- litellm/llms/exa_ai/search/transformation.py | 52 ++++++-- .../llms/exa_ai/search/test_transformation.py | 118 ++++++++++++++++++ 2 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 tests/test_litellm/llms/exa_ai/search/test_transformation.py 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")