This commit is contained in:
Priyansh Nandwana 2026-08-26 22:50:20 +03:00 • committed by GitHub
commit ed73461b11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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")