mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge 95d59aa60c into 587f227b9d
This commit is contained in:
commit
64746a5fc8
18 changed files with 726 additions and 12 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3
litellm/llms/xquik/__init__.py
Normal file
3
litellm/llms/xquik/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from litellm.llms.xquik.search.transformation import XquikSearchConfig
|
||||
|
||||
__all__ = ("XquikSearchConfig",)
|
||||
3
litellm/llms/xquik/search/__init__.py
Normal file
3
litellm/llms/xquik/search/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from litellm.llms.xquik.search.transformation import XquikSearchConfig
|
||||
|
||||
__all__ = ("XquikSearchConfig",)
|
||||
264
litellm/llms/xquik/search/transformation.py
Normal file
264
litellm/llms/xquik/search/transformation.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
search_tools:
|
||||
- search_tool_name: xquik_search
|
||||
litellm_params:
|
||||
search_provider: xquik
|
||||
api_key: os.environ/XQUIK_API_KEY
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -256,6 +256,10 @@
|
|||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"input_cost_per_result": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"input_cost_per_second": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ SEARCH_PROVIDERS = [
|
|||
"apiserpent",
|
||||
"tinyfish",
|
||||
"nimble",
|
||||
"xquik",
|
||||
]
|
||||
|
||||
ALLOWED_FILES_IN_LLMS_FOLDER = [
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue