This commit is contained in:
Burak Bayır 2026-08-27 02:28:37 +00:00 committed by GitHub
commit 64746a5fc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 726 additions and 12 deletions

View file

@ -2,8 +2,9 @@
## File for 'response_cost' calculation in Logging
import logging
import time
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from functools import lru_cache
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, cast
from httpx import Response
@ -126,6 +127,8 @@ from litellm.utils import (
token_counter,
)
_EMPTY_SEARCH_COST_PARAMS: Final[Mapping[str, object]] = MappingProxyType({})
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import (
Logging as LitellmLoggingObject,
@ -1478,6 +1481,21 @@ def completion_cost(
elif call_type in _SEARCH_CALL_TYPES:
from litellm.search import search_provider_cost_per_query
search_response_hidden_params: object = getattr(completion_response, "_hidden_params", None)
billed_results: object = (
search_response_hidden_params.get("billed_results")
if isinstance(search_response_hidden_params, Mapping)
else None
)
billed_result_params: Mapping[str, object] = (
MappingProxyType({"billed_results": billed_results})
if billed_results is not None
else MappingProxyType({})
)
search_cost_params: Mapping[str, object] = MappingProxyType(
{**(optional_params or _EMPTY_SEARCH_COST_PARAMS), **billed_result_params}
)
# Extract number_of_queries from optional_params or default to 1
number_of_queries = 1
if optional_params is not None:
@ -1500,7 +1518,7 @@ def completion_cost(
model=search_model,
custom_llm_provider=custom_llm_provider,
number_of_queries=number_of_queries,
optional_params=optional_params,
optional_params=search_cost_params,
)
# Return the total cost (prompt_cost + completion_cost, but for search it's just prompt_cost)

View file

@ -62,7 +62,7 @@ class AzurePassthroughConfig(BasePassthroughConfig):
) -> dict:
return BaseAzureLLM._base_validate_azure_environment(
headers=headers,
litellm_params=GenericLiteLLMParams(**{**litellm_params, "api_key": api_key}),
litellm_params=GenericLiteLLMParams.model_validate({**litellm_params, "api_key": api_key}),
)
@staticmethod

View file

@ -0,0 +1,3 @@
from litellm.llms.xquik.search.transformation import XquikSearchConfig
__all__ = ("XquikSearchConfig",)

View file

@ -0,0 +1,3 @@
from litellm.llms.xquik.search.transformation import XquikSearchConfig
__all__ = ("XquikSearchConfig",)

View file

@ -0,0 +1,264 @@
"""Xquik X post search adapter."""
from __future__ import annotations
import json
from collections.abc import Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Literal
from urllib.parse import urlencode
import httpx
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
_XQUIK_DOCS_URL: Final = "https://docs.xquik.com/api-reference/x/search-tweets"
_XQUIK_PARAMS_KEY: Final = "_xquik_params"
_DomainListAdapter: Final = TypeAdapter(tuple[str, ...])
_QueryParamsAdapter: Final = TypeAdapter(dict[str, str | int | float])
_AUTH_HEADER_NAMES: Final = frozenset(("authorization", "x-api-key"))
_REQUEST_PARAM_NAMES: Final = frozenset(("q", "limit", "placeCountry"))
_NO_QUERY_PARAMS: Final[Mapping[str, str | int | float]] = MappingProxyType({})
class _XquikAuthor(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
id: str | None = None
username: str | None = None
name: str | None = None
followers: int | None = None
verified: bool | None = None
profile_picture: str | None = Field(default=None, alias="profilePicture")
class _XquikTweet(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
id: str | None = None
text: str | None = None
created_at: str | None = Field(default=None, alias="createdAt")
url: str | None = None
author: _XquikAuthor | None = None
like_count: int | None = Field(default=None, alias="likeCount")
retweet_count: int | None = Field(default=None, alias="retweetCount")
reply_count: int | None = Field(default=None, alias="replyCount")
quote_count: int | None = Field(default=None, alias="quoteCount")
view_count: int | None = Field(default=None, alias="viewCount")
bookmark_count: int | None = Field(default=None, alias="bookmarkCount")
lang: str | None = None
class _XquikSearchResponse(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
tweets: tuple[_XquikTweet, ...]
has_next_page: bool
next_cursor: str
class _XquikErrorEnvelope(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
error: str | None = None
message: str | None = None
class XquikSearchConfig(BaseSearchConfig):
XQUIK_API_BASE = "https://xquik.com/api/v1"
@staticmethod
def ui_friendly_name() -> str:
return "Xquik"
def get_http_method(self) -> Literal["GET", "POST"]:
return "GET"
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: BaseSearchConfig.validate_environment passes mutable HTTP headers
api_key: str | None = None,
api_base: str | None = None,
**kwargs: object, # kwargs-ok: BaseSearchConfig forwards provider-specific validation arguments
) -> dict[str, str]: # mutable-ok: the HTTP handler requires a mutable header dictionary
if api_key:
sanitized_headers: Final = MappingProxyType(
{key: value for key, value in headers.items() if key.lower() != "x-api-key"}
)
return { # mutable-ok: the HTTP handler passes this dictionary directly to httpx
**sanitized_headers,
"x-api-key": api_key,
"Accept": "application/json",
}
if not _has_auth_header(headers):
raise ValueError("Xquik Search requires api_key or an authentication header.")
return {**headers, "Accept": "application/json"} # mutable-ok: httpx requires mutable request headers
def get_complete_url(
self,
api_base: str | None,
optional_params: dict[str, object], # mutable-ok: BaseSearchConfig.get_complete_url passes mutable options
data: dict[str, object] | list[dict[str, object]] | None = None, # mutable-ok: inherited request-body contract
**kwargs: object, # kwargs-ok: BaseSearchConfig forwards provider-specific URL arguments
) -> str:
resolved_base: Final = (api_base or self.XQUIK_API_BASE).rstrip("/")
endpoint: Final = (
resolved_base if resolved_base.endswith("/x/tweets/search") else f"{resolved_base}/x/tweets/search"
)
if isinstance(data, dict) and _XQUIK_PARAMS_KEY in data:
try:
params: Final = _QueryParamsAdapter.validate_python(data[_XQUIK_PARAMS_KEY])
except ValidationError as error:
raise ValueError("Xquik Search request parameters must be a mapping.") from error
return f"{endpoint}?{urlencode(params)}"
return endpoint
def transform_search_request(
self,
query: str | list[str], # mutable-ok: BaseSearchConfig accepts mutable query lists
optional_params: dict[str, object], # mutable-ok: BaseSearchConfig passes mutable provider options
**kwargs: object, # kwargs-ok: BaseSearchConfig forwards provider-specific request arguments
) -> dict[str, object]: # mutable-ok: the search handler requires a JSON-compatible request dictionary
resolved_query: Final = " ".join(query) if isinstance(query, list) else query
unified_params: Final = self.get_supported_perplexity_optional_params()
passthrough: Final = MappingProxyType(
{
key: _query_value(value)
for key, value in optional_params.items()
if key not in unified_params and key not in _REQUEST_PARAM_NAMES and value is not None
}
)
country: Final = optional_params.get("country")
request_params: Final = MappingProxyType(
{
"q": _append_domain_filters(resolved_query, optional_params.get("search_domain_filter")),
**_optional_query_param("limit", optional_params.get("max_results")),
**_optional_query_param("placeCountry", country.upper() if isinstance(country, str) else None),
**passthrough,
}
)
return {_XQUIK_PARAMS_KEY: request_params} # mutable-ok: handler request envelopes are mutable dictionaries
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj | None,
**kwargs: object, # kwargs-ok: BaseSearchConfig forwards provider-specific response arguments
) -> SearchResponse:
headers: Final = dict(raw_response.headers) # mutable-ok: the base error and metadata contracts require dicts
if not 200 <= raw_response.status_code < 300:
raise self.get_error_class(raw_response.text, raw_response.status_code, headers)
try:
parsed: Final = _XquikSearchResponse.model_validate_json(raw_response.content)
except ValidationError as error:
raise self.get_error_class(
f"response does not match the documented search schema: {error}",
raw_response.status_code,
headers,
)
response: Final = SearchResponse.model_validate(
MappingProxyType(
{
"results": tuple(_search_result(tweet) for tweet in parsed.tweets),
"object": "search",
"has_next_page": parsed.has_next_page,
"next_cursor": parsed.next_cursor,
}
)
)
response._hidden_params["billed_results"] = len( # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] # provider cost channel
parsed.tweets
)
response._hidden_params["headers"] = headers # pyright: ignore[reportPrivateUsage, reportUnknownMemberType] # provider metadata channel
return response
def get_error_class(
self,
error_message: str,
status_code: int,
headers: dict[str, str], # mutable-ok: BaseSearchConfig.get_error_class requires mutable headers
) -> Exception:
detail: Final = _error_detail(error_message).rstrip(". ")
return BaseLLMException(
status_code=status_code,
message=f"Xquik Search: {detail}. See {_XQUIK_DOCS_URL} for details.",
headers=headers,
)
def _has_auth_header(headers: Mapping[str, str]) -> bool:
return any(key.lower() in _AUTH_HEADER_NAMES and bool(value) for key, value in headers.items())
def _optional_query_param(key: str, value: object) -> Mapping[str, str | int | float]:
return MappingProxyType({key: _query_value(value)}) if value is not None else _NO_QUERY_PARAMS
def _query_value(value: object) -> str | int | float:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (str, int, float)):
return value
return json.dumps(value, separators=(",", ":"))
def _append_domain_filters(query: str, search_domain_filter: object) -> str:
try:
domains: Final = _DomainListAdapter.validate_python(search_domain_filter)
except ValidationError:
return query
included: Final = tuple(domain for domain in domains if domain and not domain.startswith("-"))
excluded: Final = tuple(domain[1:] for domain in domains if domain.startswith("-") and len(domain) > 1)
include_clause: Final = f" ({' OR '.join(f'url:{domain}' for domain in included)})" if included else ""
exclude_clause: Final = "".join(f" -url:{domain}" for domain in excluded)
return f"({query}){include_clause}{exclude_clause}" if included or excluded else query
def _search_result(tweet: _XquikTweet) -> SearchResult:
author: Final = tweet.author
username: Final = author.username if author else None
title: Final = _result_title(tweet)
url: Final = tweet.url or (f"https://x.com/{username}/status/{tweet.id}" if username and tweet.id else "")
return SearchResult.model_validate(
MappingProxyType(
{
"title": title,
"url": url,
"snippet": tweet.text or "",
"date": tweet.created_at,
"last_updated": None,
"xquik_tweet": tweet.model_dump(exclude_none=True, by_alias=True),
}
)
)
def _result_title(tweet: _XquikTweet) -> str:
if tweet.author:
if tweet.author.name and tweet.author.username:
return f"{tweet.author.name} (@{tweet.author.username})"
if tweet.author.name:
return tweet.author.name
if tweet.author.username:
return f"@{tweet.author.username}"
return f"X post {tweet.id}" if tweet.id else "X post"
def _error_detail(error_message: str) -> str:
try:
envelope: Final = _XquikErrorEnvelope.model_validate_json(error_message)
except ValidationError:
return error_message
return envelope.message or envelope.error or error_message

View file

@ -17268,6 +17268,15 @@
"notes": "Nimble Search API pay-as-you-go list price: $5 per 1,000 searches, up to 100 results per search. Volume plans price differently."
}
},
"xquik/search": {
"input_cost_per_result": 0.00015,
"litellm_provider": "xquik",
"mode": "search",
"metadata": {
"notes": "Xquik Search pay-as-you-go list price: $10 for 66,666 credits. Search costs 1 credit per returned post. Monthly plans price credits differently."
},
"source": "https://xquik.com/en#pricing"
},
"elevenlabs/scribe_v1": {
"input_cost_per_second": 6.11e-05,
"litellm_provider": "elevenlabs",

View file

@ -0,0 +1,5 @@
search_tools:
- search_tool_name: xquik_search
litellm_params:
search_provider: xquik
api_key: os.environ/XQUIK_API_KEY

View file

@ -8156,12 +8156,8 @@ class Router:
if ptu_error is not None and is_ptu_cost_attribution_enabled():
raise ValueError(ptu_error)
zeroed_pricing: Final = zeroed_ptu_pricing(_model_info, _litellm_params) if config_sourced else None
litellm_params: Final[LiteLLM_Params] = LiteLLM_Params(
**(
_litellm_params
if zeroed_pricing is None
else MappingProxyType({**_litellm_params, **zeroed_pricing})
)
litellm_params: Final[LiteLLM_Params] = LiteLLM_Params.model_validate(
_litellm_params if zeroed_pricing is None else MappingProxyType({**_litellm_params, **zeroed_pricing})
)
warn_on_provider_credential_mismatch(model_name=_model_name, litellm_params=_litellm_params)
deployment = Deployment(

View file

@ -2,22 +2,26 @@
Cost calculation for search providers.
"""
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
from litellm.utils import get_model_info
_NO_SEARCH_PARAMS: Final[Mapping[str, object]] = MappingProxyType({})
def search_provider_cost_per_query(
model: str,
custom_llm_provider: str | None = None,
number_of_queries: int = 1,
optional_params: dict | None = None,
optional_params: Mapping[str, object] | None = None,
) -> tuple[float, float]:
"""
Calculate cost for search-only providers.
Returns (input_cost, output_cost) where input_cost = queries * cost_per_query
Supports tiered pricing based on max_results parameter.
Supports tiered pricing and per-result pricing.
Args:
model: Model name (e.g., "exa_ai/search", "tavily/search")
@ -30,6 +34,20 @@ def search_provider_cost_per_query(
"""
model_info: Final = get_model_info(model=model, custom_llm_provider=custom_llm_provider)
input_cost_per_result: Final = model_info.get("input_cost_per_result")
if input_cost_per_result is not None:
params: Final = optional_params or _NO_SEARCH_PARAMS
billed_results: Final = params.get("billed_results")
if isinstance(billed_results, int) and not isinstance(billed_results, bool) and billed_results >= 0:
return (billed_results * float(input_cost_per_result), 0.0)
requested_results: Final = params.get("max_results")
estimated_results: Final = (
requested_results
if isinstance(requested_results, int) and not isinstance(requested_results, bool) and requested_results >= 0
else 10
)
return (number_of_queries * estimated_results * float(input_cost_per_result), 0.0)
# Check for tiered pricing (e.g., Exa AI based on max_results)
tiered_pricing: Final = model_info.get("tiered_pricing")
if tiered_pricing and isinstance(tiered_pricing, list):

View file

@ -233,7 +233,8 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
input_cost_per_token_above_272k_tokens_flex: float | None
input_cost_per_token_above_512k_tokens: float | None # MiniMax-M3: prompts >512K priced at 2x input
input_cost_per_character_above_128k_tokens: float | None # only for vertex ai models
input_cost_per_query: float | None # only for rerank models
input_cost_per_query: float | None # only for rerank and search models
input_cost_per_result: ReadOnly[float | None] # only for search models
input_cost_per_image: float | None # only for vertex ai models
input_cost_per_image_token: float | None # for gpt-image-1 and similar models
input_cost_per_video_token: float | None # for gemini omni models with video input
@ -3363,6 +3364,7 @@ class MirroredPricingParams(BaseModel):
class CustomPricingLiteLLMParams(MirroredPricingParams):
## CUSTOM PRICING ##
input_cost_per_result: float | None = None
input_cost_per_second: float | None = None
output_cost_per_second: float | None = None
output_cost_per_second_1080p: float | None = None
@ -3883,6 +3885,7 @@ class SearchProviders(str, Enum):
AGENTCORE = "agentcore"
NIMBLE = "nimble"
BING_GROUNDING = "bing_grounding"
XQUIK = "xquik"
# Create a set of all search provider values for quick lookup

View file

@ -5784,6 +5784,7 @@ def _get_model_info_helper(
),
input_cost_per_token_above_512k_tokens=_model_info.get("input_cost_per_token_above_512k_tokens", None),
input_cost_per_query=_model_info.get("input_cost_per_query", None),
input_cost_per_result=_model_info.get("input_cost_per_result", None),
input_cost_per_second=_model_info.get("input_cost_per_second", None),
input_cost_per_audio_token=_model_info.get("input_cost_per_audio_token", None),
input_cost_per_image_token=_model_info.get("input_cost_per_image_token", None),
@ -9235,6 +9236,7 @@ class ProviderConfigManager:
from litellm.llms.serper.search.transformation import SerperSearchConfig
from litellm.llms.tavily.search.transformation import TavilySearchConfig
from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig
from litellm.llms.xquik.search.transformation import XquikSearchConfig
from litellm.llms.you_com.search.transformation import YouComSearchConfig
PROVIDER_TO_CONFIG_MAP: Final = {
@ -9258,6 +9260,7 @@ class ProviderConfigManager:
SearchProviders.AGENTCORE: AgentCoreSearchConfig,
SearchProviders.NIMBLE: NimbleSearchConfig,
SearchProviders.BING_GROUNDING: BingGroundingSearchConfig,
SearchProviders.XQUIK: XquikSearchConfig,
}
config_class: Final = PROVIDER_TO_CONFIG_MAP.get(provider, None)
if config_class is None:

View file

@ -17268,6 +17268,15 @@
"notes": "Nimble Search API pay-as-you-go list price: $5 per 1,000 searches, up to 100 results per search. Volume plans price differently."
}
},
"xquik/search": {
"input_cost_per_result": 0.00015,
"litellm_provider": "xquik",
"mode": "search",
"metadata": {
"notes": "Xquik Search pay-as-you-go list price: $10 for 66,666 credits. Search costs 1 credit per returned post. Monthly plans price credits differently."
},
"source": "https://xquik.com/en#pricing"
},
"elevenlabs/scribe_v1": {
"input_cost_per_second": 6.11e-05,
"litellm_provider": "elevenlabs",

View file

@ -256,6 +256,10 @@
"type": "number",
"minimum": 0
},
"input_cost_per_result": {
"type": "number",
"minimum": 0
},
"input_cost_per_second": {
"type": "number",
"minimum": 0

View file

@ -2464,6 +2464,13 @@
"search": true
}
},
"xquik": {
"display_name": "Xquik (`xquik`)",
"url": "https://docs.xquik.com/api-reference/x/search-tweets",
"endpoints": {
"search": true
}
},
"triton": {
"display_name": "Triton (`triton`)",
"url": "https://docs.litellm.ai/docs/providers/triton-inference-server",

View file

@ -21,6 +21,7 @@ SEARCH_PROVIDERS = [
"apiserpent",
"tinyfish",
"nimble",
"xquik",
]
ALLOWED_FILES_IN_LLMS_FOLDER = [

View file

@ -0,0 +1,365 @@
import json
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from respx import MockRouter
import litellm
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.llms.xquik.search.transformation import XquikSearchConfig
from litellm.search.cost_calculator import search_provider_cost_per_query
from litellm.types.utils import SearchProviders
from litellm.utils import ProviderConfigManager, get_model_info
pytestmark = pytest.mark.usefixtures("local_model_cost_map")
class _SearchLoggingStub:
def pre_call(
self,
input: str,
api_key: str | None,
additional_args: dict[str, object],
) -> None:
return None
def _config() -> XquikSearchConfig:
return XquikSearchConfig()
def _response(payload: object, status_code: int = 200, headers: dict[str, str] | None = None) -> httpx.Response:
content = payload if isinstance(payload, str) else json.dumps(payload)
return httpx.Response(
status_code=status_code,
content=content.encode(),
headers=headers,
request=httpx.Request("GET", "https://xquik.com/api/v1/x/tweets/search"),
)
def _tweet(**overrides: object) -> dict[str, object]:
return {
"id": "1893456789012345678",
"text": "LiteLLM can now search X posts.",
"createdAt": "2026-08-24T10:00:00Z",
"url": "https://x.com/example/status/1893456789012345678",
"likeCount": 12,
"retweetCount": 3,
"replyCount": 2,
"quoteCount": 1,
"viewCount": 900,
"bookmarkCount": 4,
"lang": "en",
"author": {
"id": "42",
"username": "example",
"name": "Example User",
"followers": 100,
"verified": True,
"profilePicture": "https://example.com/avatar.jpg",
},
**overrides,
}
def _params(query: str | list[str], optional_params: dict[str, object]) -> dict[str, list[str]]:
request_data = _config().transform_search_request(query, optional_params)
url = _config().get_complete_url(None, optional_params, data=request_data)
return parse_qs(urlsplit(url).query)
def test_provider_registration_and_model_metadata() -> None:
assert SearchProviders.XQUIK.value == "xquik"
assert isinstance(
ProviderConfigManager.get_provider_search_config(SearchProviders.XQUIK),
XquikSearchConfig,
)
model_info = get_model_info("xquik/search", custom_llm_provider="xquik")
assert model_info["mode"] == "search"
assert model_info["input_cost_per_result"] == 0.00015
def test_provider_identity_and_http_method() -> None:
assert _config().ui_friendly_name() == "Xquik"
assert _config().get_http_method() == "GET"
def test_validate_environment_adds_explicit_key_without_mutating_headers() -> None:
original = {"X-Custom": "keep", "X-API-Key": "old"}
headers = _config().validate_environment(original, api_key="new")
assert original == {"X-Custom": "keep", "X-API-Key": "old"}
assert headers == {"X-Custom": "keep", "x-api-key": "new", "Accept": "application/json"}
@pytest.mark.parametrize(
"headers",
[
{"x-api-key": "key"},
{"Authorization": "Bearer guest-key"},
],
)
def test_validate_environment_accepts_documented_auth_headers(headers: dict[str, str]) -> None:
validated = _config().validate_environment(headers)
assert validated == {**headers, "Accept": "application/json"}
def test_validate_environment_is_idempotent() -> None:
once = _config().validate_environment({}, api_key="key")
twice = _config().validate_environment(once, api_key="key")
assert twice == once
def test_validate_environment_requires_authentication() -> None:
with pytest.raises(ValueError, match="requires api_key or an authentication header"):
_config().validate_environment({})
@pytest.mark.parametrize(
("api_base", "expected"),
[
(None, "https://xquik.com/api/v1/x/tweets/search"),
("https://proxy.example/v1", "https://proxy.example/v1/x/tweets/search"),
("https://proxy.example/v1/x/tweets/search", "https://proxy.example/v1/x/tweets/search"),
("https://proxy.example/v1/x/tweets/search/", "https://proxy.example/v1/x/tweets/search"),
],
)
def test_get_complete_url_appends_endpoint_once(api_base: str | None, expected: str) -> None:
assert _config().get_complete_url(api_base, {}) == expected
def test_get_complete_url_rejects_non_mapping_parameters() -> None:
with pytest.raises(ValueError, match="request parameters must be a mapping"):
_config().get_complete_url(None, {}, data={"_xquik_params": ["invalid"]})
def test_transform_request_maps_unified_parameters() -> None:
params = _params(
["latest", "launch"],
{
"max_results": 25,
"country": "us",
"max_tokens_per_page": 1024,
"queryType": "Top",
"verifiedOnly": True,
},
)
assert params == {
"q": ["latest launch"],
"limit": ["25"],
"placeCountry": ["US"],
"queryType": ["Top"],
"verifiedOnly": ["true"],
}
def test_transform_request_maps_include_and_exclude_domains() -> None:
params = _params(
"release notes",
{"search_domain_filter": ["github.com", "docs.example", "-spam.example"]},
)
assert params["q"] == ["(release notes) (url:github.com OR url:docs.example) -url:spam.example"]
@pytest.mark.parametrize("domain_filter", [None, "example.com", [1]])
def test_transform_request_ignores_invalid_domain_filters(domain_filter: object) -> None:
assert _params("query", {"search_domain_filter": domain_filter})["q"] == ["query"]
def test_transform_request_serializes_provider_specific_collections() -> None:
params = _params("query", {"custom": {"nested": [1, 2]}})
assert params["custom"] == ['{"nested":[1,2]}']
def test_transform_response_maps_standard_and_xquik_fields() -> None:
response = _config().transform_search_response(
_response(
{
"tweets": [_tweet()],
"has_next_page": True,
"next_cursor": "cursor-1",
},
headers={"x-request-id": "request-1"},
),
logging_obj=None,
)
result = response.results[0]
assert result.title == "Example User (@example)"
assert result.url == "https://x.com/example/status/1893456789012345678"
assert result.snippet == "LiteLLM can now search X posts."
assert result.date == "2026-08-24T10:00:00Z"
assert result.xquik_tweet == _tweet()
assert response.has_next_page is True
assert response.next_cursor == "cursor-1"
assert response._hidden_params["billed_results"] == 1
assert response._hidden_params["headers"]["x-request-id"] == "request-1"
@pytest.mark.parametrize(
("tweet", "expected_title", "expected_url"),
[
(
{"id": "123", "author": {"username": "somebody"}},
"@somebody",
"https://x.com/somebody/status/123",
),
({"id": "123", "author": {"name": "Somebody"}}, "Somebody", ""),
({"id": "123"}, "X post 123", ""),
({}, "X post", ""),
],
)
def test_transform_response_builds_url_and_title_for_degraded_tweet(
tweet: dict[str, object], expected_title: str, expected_url: str
) -> None:
response = _config().transform_search_response(
_response(
{
"tweets": [tweet],
"has_next_page": False,
"next_cursor": "",
}
),
logging_obj=None,
)
result = response.results[0]
assert result.title == expected_title
assert result.url == expected_url
assert result.snippet == ""
assert result.date is None
def test_transform_response_preserves_zero_result_page_and_billing() -> None:
response = _config().transform_search_response(
_response({"tweets": [], "has_next_page": False, "next_cursor": ""}),
logging_obj=None,
)
assert response.results == []
assert response._hidden_params["billed_results"] == 0
@pytest.mark.parametrize(
"payload",
[
"not json",
{},
{"tweets": None, "has_next_page": False, "next_cursor": ""},
{"tweets": ["bad"], "has_next_page": False, "next_cursor": ""},
],
)
def test_transform_response_rejects_malformed_success_body(payload: object) -> None:
with pytest.raises(Exception, match="Xquik Search: response does not match"):
_config().transform_search_response(_response(payload), logging_obj=None)
def test_transform_response_unwraps_error_and_preserves_retry_header() -> None:
with pytest.raises(Exception, match="Xquik Search: Too many requests") as exc_info:
_config().transform_search_response(
_response(
{"error": "rate_limit_exceeded", "message": "Too many requests. Try again later."},
status_code=429,
headers={"Retry-After": "60"},
),
logging_obj=None,
)
assert exc_info.value.status_code == 429
assert exc_info.value.headers["retry-after"] == "60"
assert "docs.xquik.com/api-reference/x/search-tweets" in str(exc_info.value)
def test_transform_response_uses_error_code_when_message_is_absent() -> None:
with pytest.raises(Exception, match="Xquik Search: unauthenticated"):
_config().transform_search_response(
_response({"error": "unauthenticated"}, status_code=401),
logging_obj=None,
)
@pytest.mark.respx()
def test_search_routes_through_get_and_records_exact_cost(respx_mock: MockRouter) -> None:
route = respx_mock.get(url__regex=r"https://xquik\.com/api/v1/x/tweets/search.*").respond(
json={
"tweets": [_tweet()],
"has_next_page": False,
"next_cursor": "",
},
status_code=200,
)
response = litellm.search(
query="xquik launch",
search_provider="xquik",
api_key="test-key",
max_results=1,
queryType="Latest",
)
request = route.calls[0].request
assert parse_qs(urlsplit(str(request.url)).query) == {
"q": ["xquik launch"],
"limit": ["1"],
"queryType": ["Latest"],
}
assert request.headers["x-api-key"] == "test-key"
assert response.results[0].snippet == "LiteLLM can now search X posts."
assert response._hidden_params["response_cost"] == pytest.approx(0.00015)
@pytest.mark.asyncio
async def test_async_handler_uses_injected_http_transport() -> None:
async def respond(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status_code=200,
json={
"tweets": [_tweet()],
"has_next_page": False,
"next_cursor": "",
},
request=request,
)
transport_client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
http_handler = AsyncHTTPHandler()
await http_handler.close()
http_handler.client = transport_client
response = await BaseLLMHTTPHandler().async_search(
query="xquik launch",
optional_params={"max_results": 1},
timeout=30,
logging_obj=_SearchLoggingStub(),
api_key="test-key",
api_base=None,
custom_llm_provider="xquik",
client=http_handler,
provider_config=_config(),
)
await http_handler.close()
assert response.results[0].url == "https://x.com/example/status/1893456789012345678"
def test_cost_uses_exact_returned_result_count() -> None:
assert search_provider_cost_per_query(
model="xquik/search",
custom_llm_provider="xquik",
optional_params={"billed_results": 3, "max_results": 100},
) == pytest.approx((0.00045, 0.0))
def test_cost_estimates_from_requested_results_without_response() -> None:
assert search_provider_cost_per_query(
model="xquik/search",
custom_llm_provider="xquik",
number_of_queries=2,
optional_params={"max_results": 5},
) == pytest.approx((0.0015, 0.0))
def test_cost_defaults_to_ten_results_for_estimates() -> None:
assert search_provider_cost_per_query(
model="xquik/search",
custom_llm_provider="xquik",
) == pytest.approx((0.0015, 0.0))

View file

@ -749,6 +749,7 @@ def validate_model_cost_values(model_data, exceptions=None):
"output_cost_per_second_1080p",
"output_cost_per_second_4k",
"input_cost_per_query",
"input_cost_per_result",
"input_cost_per_request",
"input_cost_per_audio_token",
"output_cost_per_audio_token",
@ -910,6 +911,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"regional_processing_uplift_multiplier_us": {"type": "number"},
"input_cost_per_pixel": {"type": "number"},
"input_cost_per_query": {"type": "number"},
"input_cost_per_result": {"type": "number"},
"input_cost_per_request": {"type": "number"},
"input_cost_per_second": {"type": "number"},
"input_cost_per_token": {"type": "number"},

View file

@ -27604,6 +27604,8 @@ export interface components {
input_cost_per_pixel?: number | null;
/** Input Cost Per Query */
input_cost_per_query?: number | null;
/** Input Cost Per Result */
input_cost_per_result?: number | null;
/** Input Cost Per Second */
input_cost_per_second?: number | null;
/** Input Cost Per Token */
@ -36833,6 +36835,8 @@ export interface components {
input_cost_per_pixel?: number | null;
/** Input Cost Per Query */
input_cost_per_query?: number | null;
/** Input Cost Per Result */
input_cost_per_result?: number | null;
/** Input Cost Per Second */
input_cost_per_second?: number | null;
/** Input Cost Per Token */