mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
Merge e9a9750a5a into c850ed3c8e
This commit is contained in:
commit
ed73461b11
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