From 9e1a32a7dbd07dab9508dca713458ceda3042928 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sat, 29 Aug 2026 06:32:53 +0200 Subject: [PATCH] feat(search): add Xquik provider --- litellm/cost_calculator.py | 20 +- .../llms/azure/passthrough/transformation.py | 2 +- litellm/llms/xquik/__init__.py | 3 + litellm/llms/xquik/search/__init__.py | 3 + litellm/llms/xquik/search/transformation.py | 264 +++++++++++++ ...odel_prices_and_context_window_backup.json | 9 + .../xquik_search_config.yaml | 5 + litellm/router.py | 8 +- litellm/search/cost_calculator.py | 22 +- litellm/types/utils.py | 5 +- litellm/utils.py | 3 + model_prices_and_context_window.json | 9 + model_prices_and_context_window.schema.json | 4 + provider_endpoints_support.json | 7 + .../enforce_llms_folder_style.py | 1 + .../test_xquik_search_transformation.py | 365 ++++++++++++++++++ tests/test_litellm/test_utils.py | 2 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 + 18 files changed, 725 insertions(+), 11 deletions(-) create mode 100644 litellm/llms/xquik/__init__.py create mode 100644 litellm/llms/xquik/search/__init__.py create mode 100644 litellm/llms/xquik/search/transformation.py create mode 100644 litellm/proxy/example_config_yaml/xquik_search_config.yaml create mode 100644 tests/test_litellm/llms/xquik/search/test_xquik_search_transformation.py diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 3adc1c25dfd..4521b43bb1e 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -4,6 +4,7 @@ import logging import time 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 @@ -129,6 +130,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, @@ -1494,6 +1497,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: @@ -1516,7 +1534,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) diff --git a/litellm/llms/azure/passthrough/transformation.py b/litellm/llms/azure/passthrough/transformation.py index 898852e645f..e3622490579 100644 --- a/litellm/llms/azure/passthrough/transformation.py +++ b/litellm/llms/azure/passthrough/transformation.py @@ -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 diff --git a/litellm/llms/xquik/__init__.py b/litellm/llms/xquik/__init__.py new file mode 100644 index 00000000000..9ba5d46dcec --- /dev/null +++ b/litellm/llms/xquik/__init__.py @@ -0,0 +1,3 @@ +from litellm.llms.xquik.search.transformation import XquikSearchConfig + +__all__ = ("XquikSearchConfig",) diff --git a/litellm/llms/xquik/search/__init__.py b/litellm/llms/xquik/search/__init__.py new file mode 100644 index 00000000000..9ba5d46dcec --- /dev/null +++ b/litellm/llms/xquik/search/__init__.py @@ -0,0 +1,3 @@ +from litellm.llms.xquik.search.transformation import XquikSearchConfig + +__all__ = ("XquikSearchConfig",) diff --git a/litellm/llms/xquik/search/transformation.py b/litellm/llms/xquik/search/transformation.py new file mode 100644 index 00000000000..bb7ddde6ebe --- /dev/null +++ b/litellm/llms/xquik/search/transformation.py @@ -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 diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bebbcc32181..a8d1611a723 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -17425,6 +17425,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", diff --git a/litellm/proxy/example_config_yaml/xquik_search_config.yaml b/litellm/proxy/example_config_yaml/xquik_search_config.yaml new file mode 100644 index 00000000000..e1660eef54b --- /dev/null +++ b/litellm/proxy/example_config_yaml/xquik_search_config.yaml @@ -0,0 +1,5 @@ +search_tools: + - search_tool_name: xquik_search + litellm_params: + search_provider: xquik + api_key: os.environ/XQUIK_API_KEY diff --git a/litellm/router.py b/litellm/router.py index c93c1753f0e..71674f3981e 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8233,12 +8233,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( diff --git a/litellm/search/cost_calculator.py b/litellm/search/cost_calculator.py index 84461115e8e..dbad3b546c2 100644 --- a/litellm/search/cost_calculator.py +++ b/litellm/search/cost_calculator.py @@ -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): diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 737361e5413..9cb58d26c5d 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -235,7 +235,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 @@ -3370,6 +3371,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 @@ -3890,6 +3892,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 diff --git a/litellm/utils.py b/litellm/utils.py index 5cd9bfc5f32..6e20c335b1d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5856,6 +5856,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), @@ -9316,6 +9317,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 = { @@ -9339,6 +9341,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: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index bebbcc32181..a8d1611a723 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -17425,6 +17425,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", diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index 3f6d3b4f910..dfca4393c42 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -268,6 +268,10 @@ "type": "number", "minimum": 0 }, + "input_cost_per_result": { + "type": "number", + "minimum": 0 + }, "input_cost_per_second": { "type": "number", "minimum": 0 diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 1d8d374c2c4..d967e387411 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -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", diff --git a/tests/code_coverage_tests/enforce_llms_folder_style.py b/tests/code_coverage_tests/enforce_llms_folder_style.py index a284cf9e1a9..a4decd23a9a 100644 --- a/tests/code_coverage_tests/enforce_llms_folder_style.py +++ b/tests/code_coverage_tests/enforce_llms_folder_style.py @@ -21,6 +21,7 @@ SEARCH_PROVIDERS = [ "apiserpent", "tinyfish", "nimble", + "xquik", ] ALLOWED_FILES_IN_LLMS_FOLDER = [ diff --git a/tests/test_litellm/llms/xquik/search/test_xquik_search_transformation.py b/tests/test_litellm/llms/xquik/search/test_xquik_search_transformation.py new file mode 100644 index 00000000000..9d55e958559 --- /dev/null +++ b/tests/test_litellm/llms/xquik/search/test_xquik_search_transformation.py @@ -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)) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 1ff50bd0116..fd02d1278db 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -764,6 +764,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", @@ -925,6 +926,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"}, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index eaa05ddc005..72548cda051 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -28587,6 +28587,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 */ @@ -38257,6 +38259,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 */