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.
This commit is contained in:
Priyansh Nandwana 2026-08-16 14:31:48 +05:30
parent 973329e986
commit e9a9750a5a
2 changed files with 163 additions and 7 deletions

View file

@ -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,

View file

@ -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")