mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
parent
973329e986
commit
e9a9750a5a
2 changed files with 163 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
118
tests/test_litellm/llms/exa_ai/search/test_transformation.py
Normal file
118
tests/test_litellm/llms/exa_ai/search/test_transformation.py
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue