mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
feat!: add Hostinger Router as an OpenAI-compatible provider (#44863)
* feat: add Hostinger Router as an OpenAI-compatible provider Adds hostinger_router (https://router.hostinger.com/v1) to the JSON-configured openai_like providers with chat completions, responses, messages, and embeddings endpoints, cost-map entries for claude-sonnet-5, gpt-5.2 and the two text-embedding-3 models (credits converted at 1 credit = $0.01, sourced from the public https://router.hostinger.com/v1/models catalog), the endpoint-support matrix, Admin UI add-model form fields and logo, and respx-mocked unit tests covering all declared endpoints. Also routes /v1/embeddings for JSON-configured providers that declare it in providers.json; previously embedding calls for these providers fell through to LiteLLMUnknownProvider. * fix(hostinger_router): match prices to the live catalog, drop retired max_tokens, regenerate the endpoint matrix * fix(hostinger_router): keep api_base detection off, send embeddings headers as HTTP headers, drop generic key fallback Unprefixed models called with Hostinger's api_base keep their own provider, JSON provider embeddings no longer borrow OPENAI_LIKE_API_KEY or litellm.api_key, and the openai_like embedding handler sends extra_headers as request headers instead of in the JSON body * fix(openai_like): route Hostinger messages and responses per model, keep the configured key header Native /v1/messages and /v1/responses calls for a JSON provider now also follow the model's cost-map supported_endpoints, so Hostinger models without the route go through chat completions instead of a 403. Embedding key headers replace extra_headers case-insensitively, and pinstripes stops declaring /v1/embeddings. * fix(openai_like): let embeddings extra_headers override the key's headers as chat does Embeddings now match chat through the OpenAI SDK, so an extra_headers entry replaces the key's header of the same name in any letter case. The Hostinger capability test checks that the supports_* lookups agree with each cost-map row instead of pinning vendor flags * fix(openai_like): send numeric embeddings extra_headers as text and keep Hostinger's 1h cache TTL A config.yaml header like X-Org-Id: 12345 loads as an int, which the embeddings header check rejected with a 500. Int and float values now go out as their decimal text. Hostinger Router accepts and bills a 1h cache_control TTL on /v1/messages, so its provider entry opts into keeping it. * fix(openai_like): send Hostinger's 1h cache TTL as the 5-minute default so cache writes bill at its charge * fix(openai_like): route Hostinger natives per model from providers.json, refuse non-text embeddings headers unless drop_params * fix(openai_like): refuse Host, Content-Length and Transfer-Encoding in embeddings extra_headers unless drop_params * fix(openai_like): refuse malformed embeddings extra_headers and ignore transport headers Header names must be HTTP tokens and values printable ASCII on one line, so a colon in a name or a line break in a value answers 400 unless drop_params drops it. Host, Content-Length and Transfer-Encoding are ignored, since a 400 for them sends the call to the deployment's fallback with the same headers. * fix(openai_like): trim embeddings extra_headers values and drop values HTTP cannot carry HTTP ignores spaces and tabs around a header value, so values are trimmed before they are sent. A value that is not printable ASCII on one line after trimming is dropped with a warning instead of failing the call, since the proxy adds headers itself from key aliases and forwarded client headers that the caller cannot fix. A malformed header name or a non-text value still answers 400 unless drop_params drops it. * fix(openai_like): ignore hop-by-hop extra_headers and trim line breaks around values TE, Upgrade, Connection, Keep-Alive, Proxy-Connection and Trailer in extra_headers are now ignored like Host, Content-Length and Transfer-Encoding, since litellm builds the request and TE: gzip fails HTTP/2 while Upgrade makes some upstreams hang. Line breaks at the ends of a value are trimmed along with spaces and tabs, so a secret read from a file with a trailing newline is sent instead of dropped. The drop warning now names the model. * fix(embeddings): keep one request's extra_headers out of the deployment's headers and ignore Expect embedding() merged a request's extra_headers into the headers dict it got from the deployment's litellm_params. The router only shallow-copies those params, so the merge wrote the caller's headers into the deployment itself, and later embeddings calls through that deployment sent them too. Build a new dict instead, and drop the None checks that can no longer be true. Also treat Expect as a transport header that hostinger_router embeddings never forwards, and name the model with %r in the dropped-value warning. * fix(embeddings): keep name and value pairs working in headers and answer 400 for other non-object values The previous commit merged headers and extra_headers with dict unpacking, which only takes a mapping, so a list of name and value pairs that dict.update used to accept now raised a TypeError and the proxy answered 500. Merge through dict() so pairs keep working, and answer 400 for any other value, or drop it under drop_params, as the litellm convention does for an unsupported param. --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
a7b66dd8b9
commit
57090e94a4
17 changed files with 1047 additions and 25 deletions
|
|
@ -1165,6 +1165,7 @@ openai_compatible_providers: Final[list[str]] = [
|
|||
"cortecs",
|
||||
"scx-ai",
|
||||
"prism",
|
||||
"hostinger_router",
|
||||
"sail",
|
||||
"reka",
|
||||
"streamlake",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class DatabricksEmbeddingHandler(OpenAILikeEmbeddingHandler, DatabricksBase):
|
|||
aembedding=None,
|
||||
custom_endpoint: bool | None = None,
|
||||
headers: dict | None = None,
|
||||
drop_params: bool = False,
|
||||
) -> EmbeddingResponse:
|
||||
# Check for custom user agent in optional_params or environment
|
||||
# This allows partners building on LiteLLM to set their own telemetry
|
||||
|
|
@ -58,4 +59,5 @@ class DatabricksEmbeddingHandler(OpenAILikeEmbeddingHandler, DatabricksBase):
|
|||
aembedding=aembedding,
|
||||
custom_endpoint=True,
|
||||
headers=headers,
|
||||
drop_params=drop_params,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -125,6 +125,20 @@ The provider inherits all request/response handling from OpenAI's Responses API
|
|||
|
||||
If `supported_endpoints` is omitted, it defaults to `[]` (only chat completions, which is always enabled for JSON providers).
|
||||
|
||||
When only some models serve an endpoint natively, list them under `model_supported_endpoints`. Every other model reaches `/v1/responses` and `/v1/messages` through chat completions:
|
||||
|
||||
```json
|
||||
{
|
||||
"your_provider": {
|
||||
"supported_endpoints": ["/v1/chat/completions"],
|
||||
"model_supported_endpoints": {
|
||||
"model-a": ["/v1/messages"],
|
||||
"model-b": ["/v1/responses"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. `json_loader.py` checks `supported_endpoints` for `/v1/responses`
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@
|
|||
## Allows jina ai embedding calls - which don't allow 'encoding_format' in payload.
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
HTTPHandler,
|
||||
|
|
@ -17,6 +22,57 @@ from litellm.types.utils import EmbeddingResponse
|
|||
|
||||
from ..common_utils import OpenAILikeBase, OpenAILikeError
|
||||
|
||||
_KEY_HEADERS_ADAPTER: Final = TypeAdapter(Mapping[str, str])
|
||||
_EXTRA_HEADERS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
_TRANSPORT_HEADER_NAMES: Final = frozenset(
|
||||
{
|
||||
"host",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"expect",
|
||||
}
|
||||
)
|
||||
_HEADER_NAME: Final = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
|
||||
_HEADER_VALUE: Final = re.compile(r"[\t\x20-\x7e]*")
|
||||
|
||||
|
||||
def _header_text(name: str, value: object) -> str | None:
|
||||
if isinstance(value, bool) or not isinstance(value, str | int | float):
|
||||
return None
|
||||
if _HEADER_NAME.fullmatch(name) is None:
|
||||
return None
|
||||
return str(value).strip(" \t\r\n")
|
||||
|
||||
|
||||
def _extra_headers_as_text(extra_headers: Mapping[str, object], model: str, drop_params: bool) -> Mapping[str, str]:
|
||||
caller_set: Final = {
|
||||
name: value for name, value in extra_headers.items() if name.lower() not in _TRANSPORT_HEADER_NAMES
|
||||
}
|
||||
texts: Final = MappingProxyType({name: _header_text(name, value) for name, value in caller_set.items()})
|
||||
refused: Final = sorted(name for name, text in texts.items() if text is None)
|
||||
if refused and not drop_params:
|
||||
raise litellm.UnsupportedParamsError(
|
||||
message=f"extra_headers {', '.join(map(repr, refused))} cannot be sent. A header needs a name made of "
|
||||
"letters, digits or !#$%&'*+-.^_`|~ and a text or number value. "
|
||||
"To drop them from the call, set `litellm.drop_params = True`.",
|
||||
model=model,
|
||||
)
|
||||
named: Final = MappingProxyType({name: text for name, text in texts.items() if text is not None})
|
||||
unsendable: Final = sorted(name for name, text in named.items() if _HEADER_VALUE.fullmatch(text) is None)
|
||||
if unsendable:
|
||||
verbose_logger.warning(
|
||||
"extra_headers %s dropped for model %r, since a header value must be printable ASCII on one line",
|
||||
unsendable,
|
||||
model,
|
||||
)
|
||||
return MappingProxyType({name: text for name, text in named.items() if name not in unsendable})
|
||||
|
||||
|
||||
class OpenAILikeEmbeddingHandler(OpenAILikeBase):
|
||||
def __init__(self, **kwargs):
|
||||
|
|
@ -94,6 +150,7 @@ class OpenAILikeEmbeddingHandler(OpenAILikeBase):
|
|||
aembedding=None,
|
||||
custom_endpoint: bool | None = None,
|
||||
headers: dict | None = None,
|
||||
drop_params: bool = False,
|
||||
) -> EmbeddingResponse:
|
||||
api_base, headers = self._validate_environment(
|
||||
api_base=api_base,
|
||||
|
|
@ -103,8 +160,20 @@ class OpenAILikeEmbeddingHandler(OpenAILikeBase):
|
|||
custom_endpoint=custom_endpoint,
|
||||
)
|
||||
model = model
|
||||
filtered_optional_params: Final = {k: v for k, v in optional_params.items() if v not in (None, "")}
|
||||
data: Final = {"model": model, "input": input, **filtered_optional_params}
|
||||
data: Final = {
|
||||
"model": model,
|
||||
"input": input,
|
||||
**{k: v for k, v in optional_params.items() if k != "extra_headers" and v not in (None, "")},
|
||||
}
|
||||
extra_headers: Final = _extra_headers_as_text(
|
||||
_EXTRA_HEADERS_ADAPTER.validate_python(optional_params.get("extra_headers") or {}), model, drop_params
|
||||
)
|
||||
key_headers: Final = _KEY_HEADERS_ADAPTER.validate_python(headers)
|
||||
extra_header_names: Final = frozenset(name.lower() for name in extra_headers)
|
||||
request_headers: Final = {
|
||||
**{name: value for name, value in key_headers.items() if name.lower() not in extra_header_names},
|
||||
**extra_headers,
|
||||
}
|
||||
|
||||
## LOGGING
|
||||
logging_obj.pre_call(
|
||||
|
|
@ -123,7 +192,7 @@ class OpenAILikeEmbeddingHandler(OpenAILikeBase):
|
|||
api_key=api_key,
|
||||
timeout=timeout,
|
||||
client=client,
|
||||
headers=headers,
|
||||
headers=request_headers,
|
||||
)
|
||||
if client is None or isinstance(client, AsyncHTTPHandler):
|
||||
self.client = HTTPHandler(timeout=timeout)
|
||||
|
|
@ -134,7 +203,7 @@ class OpenAILikeEmbeddingHandler(OpenAILikeBase):
|
|||
try:
|
||||
response: Final = self.client.post(
|
||||
api_base,
|
||||
headers=headers,
|
||||
headers=request_headers,
|
||||
data=json.dumps(data),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ JSON-based provider configuration loader for OpenAI-compatible providers.
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ from litellm.types.utils import ProviderSpecificModelInfo
|
|||
|
||||
_PROVIDER_INFO_ADAPTER: Final = TypeAdapter(ProviderSpecificModelInfo)
|
||||
_SPECIAL_HANDLING_ADAPTER: Final = TypeAdapter(Mapping[str, bool])
|
||||
_MODEL_ENDPOINTS_ADAPTER: Final = TypeAdapter(Mapping[str, tuple[str, ...]])
|
||||
|
||||
|
||||
class SimpleProviderConfig:
|
||||
|
|
@ -30,7 +31,10 @@ class SimpleProviderConfig:
|
|||
self.special_handling: Mapping[str, bool] = _SPECIAL_HANDLING_ADAPTER.validate_python(
|
||||
data.get("special_handling", {})
|
||||
)
|
||||
self.supported_endpoints = data.get("supported_endpoints", [])
|
||||
self.supported_endpoints: Sequence[str] = data.get("supported_endpoints", [])
|
||||
self.model_supported_endpoints: Mapping[str, tuple[str, ...]] = _MODEL_ENDPOINTS_ADAPTER.validate_python(
|
||||
data.get("model_supported_endpoints", {})
|
||||
)
|
||||
self.provider_info: ProviderSpecificModelInfo = (
|
||||
_PROVIDER_INFO_ADAPTER.validate_python(data["provider_info"]) if "provider_info" in data else {}
|
||||
)
|
||||
|
|
@ -90,6 +94,23 @@ class JSONProviderRegistry:
|
|||
return False
|
||||
return "/v1/responses" in provider.supported_endpoints
|
||||
|
||||
@classmethod
|
||||
def supports_embeddings(cls, slug: str) -> bool:
|
||||
provider: Final = cls._providers.get(slug)
|
||||
if provider is None:
|
||||
return False
|
||||
return "/v1/embeddings" in provider.supported_endpoints
|
||||
|
||||
@classmethod
|
||||
def serves_endpoint(cls, slug: str, model: str | None, endpoint: str) -> bool:
|
||||
provider: Final = cls._providers.get(slug)
|
||||
if provider is None:
|
||||
return False
|
||||
model_endpoints: Final = (
|
||||
provider.model_supported_endpoints.get(model.removeprefix(f"{slug}/"), ()) if model else ()
|
||||
)
|
||||
return endpoint in provider.supported_endpoints or endpoint in model_endpoints
|
||||
|
||||
@classmethod
|
||||
def list_providers(cls) -> list:
|
||||
"""List all registered provider slugs"""
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@
|
|||
"param_mappings": {
|
||||
"max_completion_tokens": "max_tokens"
|
||||
},
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/embeddings"]
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
|
||||
},
|
||||
"scx-ai": {
|
||||
"base_url": "https://api.scx.ai/v1",
|
||||
|
|
@ -231,6 +231,16 @@
|
|||
"api_base_env": "PRISM_API_BASE",
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"]
|
||||
},
|
||||
"hostinger_router": {
|
||||
"base_url": "https://router.hostinger.com/v1",
|
||||
"api_key_env": "HOSTINGER_ROUTER_API_KEY",
|
||||
"api_base_env": "HOSTINGER_ROUTER_API_BASE",
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/embeddings"],
|
||||
"model_supported_endpoints": {
|
||||
"claude-sonnet-5": ["/v1/messages"],
|
||||
"gpt-5.2": ["/v1/responses"]
|
||||
}
|
||||
},
|
||||
"sail": {
|
||||
"base_url": "https://api.sailresearch.com/v1",
|
||||
"api_key_env": "SAIL_API_KEY",
|
||||
|
|
|
|||
|
|
@ -6809,6 +6809,23 @@ async def aembedding(*args, **kwargs) -> EmbeddingResponse:
|
|||
)
|
||||
|
||||
|
||||
def _embedding_headers(param: str, configured: object, model: str, drop_params: bool) -> Mapping[str, object]:
|
||||
if not configured:
|
||||
return {}
|
||||
if isinstance(configured, Mapping):
|
||||
return dict(cast(Mapping[str, object], configured)) # cast-ok: header names from untyped request JSON
|
||||
try:
|
||||
return dict(cast(Iterable[tuple[str, object]], configured)) # cast-ok: name and value pairs from request JSON
|
||||
except (TypeError, ValueError):
|
||||
if drop_params:
|
||||
return {}
|
||||
raise litellm.UnsupportedParamsError(
|
||||
message=f"{param} must be a JSON object of header names to values. "
|
||||
"To drop it from the call, set `litellm.drop_params = True`.",
|
||||
model=model,
|
||||
)
|
||||
|
||||
|
||||
# fmt: off
|
||||
|
||||
# Overload for when aembedding=True (returns coroutine)
|
||||
|
|
@ -6923,11 +6940,10 @@ def embedding(
|
|||
azure_ad_token_provider: Final = kwargs.get("azure_ad_token_provider", None)
|
||||
aembedding: Final[bool | None] = kwargs.get("aembedding", None)
|
||||
extra_headers: Final = kwargs.get("extra_headers", None)
|
||||
headers = kwargs.get("headers", None) or extra_headers
|
||||
if headers is None:
|
||||
headers = {}
|
||||
if extra_headers is not None:
|
||||
headers.update(extra_headers)
|
||||
headers = {
|
||||
**_embedding_headers("headers", kwargs.get("headers"), model, drop_unsupported_params),
|
||||
**_embedding_headers("extra_headers", extra_headers, model, drop_unsupported_params),
|
||||
}
|
||||
# Inject proxy auth headers if configured
|
||||
if litellm.proxy_auth is not None:
|
||||
try:
|
||||
|
|
@ -7091,7 +7107,7 @@ def embedding(
|
|||
# set API KEY
|
||||
api_key = api_key or litellm.api_key or litellm.openai_key or get_secret_str("OPENAI_API_KEY")
|
||||
|
||||
if headers is not None and headers != {}:
|
||||
if headers:
|
||||
optional_params["extra_headers"] = headers
|
||||
|
||||
requested_encoding_format: Final = (
|
||||
|
|
@ -7183,14 +7199,15 @@ def embedding(
|
|||
custom_llm_provider == "openai_like"
|
||||
or custom_llm_provider == "llamafile"
|
||||
or custom_llm_provider == "lm_studio"
|
||||
or JSONProviderRegistry.supports_embeddings(custom_llm_provider)
|
||||
):
|
||||
api_base = api_base or litellm.api_base or get_secret_str("OPENAI_LIKE_API_BASE")
|
||||
|
||||
# set API KEY
|
||||
if api_key is None:
|
||||
api_key = api_key or litellm.api_key or litellm.openai_like_key or get_secret_str("OPENAI_LIKE_API_KEY")
|
||||
if api_key is None and not JSONProviderRegistry.supports_embeddings(custom_llm_provider):
|
||||
api_key = litellm.api_key or litellm.openai_like_key or get_secret_str("OPENAI_LIKE_API_KEY")
|
||||
|
||||
if headers is not None and headers != {}:
|
||||
if headers:
|
||||
optional_params["extra_headers"] = headers
|
||||
|
||||
## EMBEDDING CALL
|
||||
|
|
@ -7205,10 +7222,9 @@ def embedding(
|
|||
optional_params=optional_params,
|
||||
client=client,
|
||||
aembedding=aembedding,
|
||||
drop_params=drop_unsupported_params,
|
||||
)
|
||||
elif custom_llm_provider == "oci":
|
||||
if headers is None:
|
||||
headers = {}
|
||||
response = base_llm_http_handler.embedding(
|
||||
model=model,
|
||||
input=input,
|
||||
|
|
@ -7233,11 +7249,6 @@ def embedding(
|
|||
or litellm.api_key
|
||||
)
|
||||
|
||||
# Use the merged headers variable (already merged at the top of the function)
|
||||
# Don't overwrite it with just extra_headers
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
response = base_llm_http_handler.embedding(
|
||||
model=model,
|
||||
input=input,
|
||||
|
|
|
|||
|
|
@ -39687,6 +39687,57 @@
|
|||
"/v1/images/generations"
|
||||
]
|
||||
},
|
||||
"hostinger_router/gpt-5.2": {
|
||||
"cache_read_input_token_cost": 0.00000021,
|
||||
"input_cost_per_token": 0.0000021,
|
||||
"litellm_provider": "hostinger_router",
|
||||
"max_input_tokens": 272000,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 0.0000168,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_vision": true,
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
|
||||
"source": "https://router.hostinger.com/v1/models"
|
||||
},
|
||||
"hostinger_router/claude-sonnet-5": {
|
||||
"cache_creation_input_token_cost": 3.3e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 5.28e-06,
|
||||
"cache_read_input_token_cost": 2.64e-07,
|
||||
"input_cost_per_token": 2.64e-06,
|
||||
"litellm_provider": "hostinger_router",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.32e-05,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_response_schema": true,
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/messages"],
|
||||
"source": "https://router.hostinger.com/v1/models"
|
||||
},
|
||||
"hostinger_router/text-embedding-3-large": {
|
||||
"input_cost_per_token": 1.716e-07,
|
||||
"litellm_provider": "hostinger_router",
|
||||
"max_input_tokens": 8191,
|
||||
"mode": "embedding",
|
||||
"output_cost_per_token": 0.0,
|
||||
"source": "https://router.hostinger.com/v1/models"
|
||||
},
|
||||
"hostinger_router/text-embedding-3-small": {
|
||||
"input_cost_per_token": 3e-08,
|
||||
"litellm_provider": "hostinger_router",
|
||||
"max_input_tokens": 8191,
|
||||
"mode": "embedding",
|
||||
"output_cost_per_token": 0.0,
|
||||
"source": "https://router.hostinger.com/v1/models"
|
||||
},
|
||||
"hyperbolic/NousResearch/Hermes-3-Llama-3.1-70B": {
|
||||
"input_cost_per_token": 1.2e-07,
|
||||
"litellm_provider": "hyperbolic",
|
||||
|
|
|
|||
|
|
@ -1087,6 +1087,32 @@
|
|||
"generateContent": false
|
||||
}
|
||||
},
|
||||
"hostinger_router": {
|
||||
"display_name": "Hostinger Router (`hostinger_router`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/hostinger_router",
|
||||
"endpoints": {
|
||||
"chat_completions": true,
|
||||
"responses": true,
|
||||
"text_completion": true,
|
||||
"embeddings": true,
|
||||
"image_generations": false,
|
||||
"image_edits": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"rerank": false,
|
||||
"ocr": false,
|
||||
"search": false,
|
||||
"moderations": false,
|
||||
"realtime": false,
|
||||
"video_generations": false,
|
||||
"batches": false,
|
||||
"messages": true,
|
||||
"interactions": true,
|
||||
"systemone": false,
|
||||
"a2a": true,
|
||||
"generateContent": true
|
||||
}
|
||||
},
|
||||
"crusoe": {
|
||||
"display_name": "Crusoe (`crusoe`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/crusoe",
|
||||
|
|
|
|||
|
|
@ -3343,6 +3343,34 @@
|
|||
],
|
||||
"default_model_placeholder": "prism/deepseek-v4.1-flash"
|
||||
},
|
||||
{
|
||||
"provider": "HOSTINGER_ROUTER",
|
||||
"provider_display_name": "Hostinger Router",
|
||||
"litellm_provider": "hostinger_router",
|
||||
"credential_fields": [
|
||||
{
|
||||
"key": "api_base",
|
||||
"label": "API Base",
|
||||
"placeholder": "https://router.hostinger.com/v1",
|
||||
"tooltip": null,
|
||||
"required": false,
|
||||
"field_type": "text",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"placeholder": null,
|
||||
"tooltip": null,
|
||||
"required": true,
|
||||
"field_type": "password",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
}
|
||||
],
|
||||
"default_model_placeholder": "hostinger_router/claude-sonnet-5"
|
||||
},
|
||||
{
|
||||
"provider": "RECRAFT",
|
||||
"provider_display_name": "Recraft",
|
||||
|
|
|
|||
|
|
@ -4683,6 +4683,7 @@ class LlmProviders(str, Enum):
|
|||
PRISM = "prism"
|
||||
OPENINFER = "openinfer"
|
||||
INFERENCEAPIS = "inferenceapis"
|
||||
HOSTINGER_ROUTER = "hostinger_router"
|
||||
ZEROGPU = "zerogpu"
|
||||
DARKBLOOM = "darkbloom"
|
||||
META = "meta"
|
||||
|
|
|
|||
|
|
@ -10159,7 +10159,7 @@ class ProviderConfigManager:
|
|||
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
|
||||
|
||||
json_provider: Final = JSONProviderRegistry.get(provider.value)
|
||||
if json_provider is not None and "/v1/messages" in json_provider.supported_endpoints:
|
||||
if json_provider is not None and JSONProviderRegistry.serves_endpoint(provider.value, model, "/v1/messages"):
|
||||
from litellm.llms.openai_like.messages.transformation import (
|
||||
JSONProviderAnthropicMessagesConfig,
|
||||
)
|
||||
|
|
@ -10298,7 +10298,7 @@ class ProviderConfigManager:
|
|||
return result
|
||||
|
||||
# Fall back to JSON providers (generic OpenAI-compatible)
|
||||
if JSONProviderRegistry.exists(provider_str) and JSONProviderRegistry.supports_responses_api(provider_str):
|
||||
if JSONProviderRegistry.serves_endpoint(provider_str, model, "/v1/responses"):
|
||||
provider_config: Final = JSONProviderRegistry.get(provider_str)
|
||||
if provider_config is not None:
|
||||
return create_responses_config_class(provider_config)()
|
||||
|
|
|
|||
|
|
@ -39687,6 +39687,57 @@
|
|||
"/v1/images/generations"
|
||||
]
|
||||
},
|
||||
"hostinger_router/gpt-5.2": {
|
||||
"cache_read_input_token_cost": 0.00000021,
|
||||
"input_cost_per_token": 0.0000021,
|
||||
"litellm_provider": "hostinger_router",
|
||||
"max_input_tokens": 272000,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 0.0000168,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_vision": true,
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
|
||||
"source": "https://router.hostinger.com/v1/models"
|
||||
},
|
||||
"hostinger_router/claude-sonnet-5": {
|
||||
"cache_creation_input_token_cost": 3.3e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 5.28e-06,
|
||||
"cache_read_input_token_cost": 2.64e-07,
|
||||
"input_cost_per_token": 2.64e-06,
|
||||
"litellm_provider": "hostinger_router",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.32e-05,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_response_schema": true,
|
||||
"supported_endpoints": ["/v1/chat/completions", "/v1/messages"],
|
||||
"source": "https://router.hostinger.com/v1/models"
|
||||
},
|
||||
"hostinger_router/text-embedding-3-large": {
|
||||
"input_cost_per_token": 1.716e-07,
|
||||
"litellm_provider": "hostinger_router",
|
||||
"max_input_tokens": 8191,
|
||||
"mode": "embedding",
|
||||
"output_cost_per_token": 0.0,
|
||||
"source": "https://router.hostinger.com/v1/models"
|
||||
},
|
||||
"hostinger_router/text-embedding-3-small": {
|
||||
"input_cost_per_token": 3e-08,
|
||||
"litellm_provider": "hostinger_router",
|
||||
"max_input_tokens": 8191,
|
||||
"mode": "embedding",
|
||||
"output_cost_per_token": 0.0,
|
||||
"source": "https://router.hostinger.com/v1/models"
|
||||
},
|
||||
"hyperbolic/NousResearch/Hermes-3-Llama-3.1-70B": {
|
||||
"input_cost_per_token": 1.2e-07,
|
||||
"litellm_provider": "hyperbolic",
|
||||
|
|
|
|||
|
|
@ -1087,6 +1087,32 @@
|
|||
"generateContent": false
|
||||
}
|
||||
},
|
||||
"hostinger_router": {
|
||||
"display_name": "Hostinger Router (`hostinger_router`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/hostinger_router",
|
||||
"endpoints": {
|
||||
"chat_completions": true,
|
||||
"responses": true,
|
||||
"text_completion": true,
|
||||
"embeddings": true,
|
||||
"image_generations": false,
|
||||
"image_edits": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": false,
|
||||
"rerank": false,
|
||||
"ocr": false,
|
||||
"search": false,
|
||||
"moderations": false,
|
||||
"realtime": false,
|
||||
"video_generations": false,
|
||||
"batches": false,
|
||||
"messages": true,
|
||||
"interactions": true,
|
||||
"systemone": false,
|
||||
"a2a": true,
|
||||
"generateContent": true
|
||||
}
|
||||
},
|
||||
"crusoe": {
|
||||
"display_name": "Crusoe (`crusoe`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/crusoe",
|
||||
|
|
|
|||
706
tests/unit/llms/openai_like/test_hostinger_router_provider.py
Normal file
706
tests/unit/llms/openai_like/test_hostinger_router_provider.py
Normal file
|
|
@ -0,0 +1,706 @@
|
|||
import copy
|
||||
import json
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.caching.llm_caching_handler import LLMClientCache
|
||||
|
||||
|
||||
def test_hostinger_router_provider_resolution(monkeypatch: pytest.MonkeyPatch):
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
monkeypatch.setenv("HOSTINGER_ROUTER_API_KEY", "hostinger-test-key")
|
||||
|
||||
model, provider, api_key, api_base = get_llm_provider(
|
||||
model="hostinger_router/claude-sonnet-5",
|
||||
custom_llm_provider=None,
|
||||
api_base=None,
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
assert model == "claude-sonnet-5"
|
||||
assert provider == "hostinger_router"
|
||||
assert api_key == "hostinger-test-key"
|
||||
assert api_base == "https://router.hostinger.com/v1"
|
||||
|
||||
|
||||
def test_hostinger_router_provider_keeps_explicit_credentials(monkeypatch: pytest.MonkeyPatch):
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
monkeypatch.setenv("HOSTINGER_ROUTER_API_KEY", "hostinger-env-key")
|
||||
|
||||
_, provider, api_key, api_base = get_llm_provider(
|
||||
model="hostinger_router/claude-sonnet-5",
|
||||
custom_llm_provider=None,
|
||||
api_base="https://router.internal.example/v1",
|
||||
api_key="hostinger-explicit-key",
|
||||
)
|
||||
|
||||
assert provider == "hostinger_router"
|
||||
assert api_key == "hostinger-explicit-key"
|
||||
assert api_base == "https://router.internal.example/v1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["gpt-5-nano", "claude-sonnet-5"])
|
||||
def test_hostinger_router_api_base_keeps_unprefixed_model_provider(model: str):
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
|
||||
_, provider_without_api_base, _, _ = get_llm_provider(model=model)
|
||||
_, provider_with_hostinger_api_base, _, _ = get_llm_provider(
|
||||
model=model,
|
||||
api_base="https://router.hostinger.com/v1",
|
||||
api_key="hostinger-test-key",
|
||||
)
|
||||
|
||||
assert provider_with_hostinger_api_base == provider_without_api_base
|
||||
|
||||
|
||||
HOSTINGER_ROUTER_MODELS = tuple(sorted(name for name in litellm.model_cost if name.startswith("hostinger_router/")))
|
||||
HOSTINGER_ROUTER_CHAT_MODELS = tuple(
|
||||
name for name in HOSTINGER_ROUTER_MODELS if litellm.get_model_info(name)["mode"] == "chat"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", HOSTINGER_ROUTER_CHAT_MODELS)
|
||||
def test_hostinger_router_model_cost_and_capabilities(model: str):
|
||||
from litellm.cost_calculator import cost_per_token
|
||||
|
||||
prompt_cost, completion_cost = cost_per_token(
|
||||
model=model,
|
||||
prompt_tokens=1_000_000,
|
||||
completion_tokens=1_000_000,
|
||||
custom_llm_provider="hostinger_router",
|
||||
)
|
||||
model_info = litellm.get_model_info(model)
|
||||
|
||||
assert prompt_cost == pytest.approx(model_info["input_cost_per_token"] * 1_000_000)
|
||||
assert completion_cost == pytest.approx(model_info["output_cost_per_token"] * 1_000_000)
|
||||
assert model_info["output_cost_per_token"] > 0
|
||||
assert model_info["max_tokens"] == model_info["max_output_tokens"] <= model_info["max_input_tokens"]
|
||||
assert model_info["litellm_provider"] == "hostinger_router"
|
||||
assert model_info["mode"] == "chat"
|
||||
if model_info.get("supports_prompt_caching"):
|
||||
assert 0 < model_info["cache_read_input_token_cost"] < model_info["input_cost_per_token"]
|
||||
capability_checks: Final = (
|
||||
("supports_function_calling", litellm.supports_function_calling),
|
||||
("supports_reasoning", litellm.supports_reasoning),
|
||||
("supports_response_schema", litellm.supports_response_schema),
|
||||
("supports_vision", litellm.supports_vision),
|
||||
)
|
||||
assert {name: check(model) for name, check in capability_checks} == {
|
||||
name: model_info.get(name) is True for name, _ in capability_checks
|
||||
}
|
||||
|
||||
|
||||
def test_hostinger_router_chat_completions_request():
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/chat/completions").respond(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl_hostinger",
|
||||
"object": "chat.completion",
|
||||
"created": 1_789_550_000,
|
||||
"model": "claude-sonnet-5",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello from Hostinger Router"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7},
|
||||
},
|
||||
)
|
||||
response: Final = litellm.completion(
|
||||
model="hostinger_router/claude-sonnet-5",
|
||||
messages=[{"role": "user", "content": "Say hello"}],
|
||||
api_key="hostinger-test-key",
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
body: Final = json.loads(request.content)
|
||||
assert route.call_count == 1
|
||||
assert request.headers["authorization"] == "Bearer hostinger-test-key"
|
||||
assert body["model"] == "claude-sonnet-5"
|
||||
assert response.choices[0].message.content == "Hello from Hostinger Router"
|
||||
|
||||
|
||||
def test_hostinger_router_chat_completions_streaming_request():
|
||||
chunks = [
|
||||
'data: {"id":"chatcmpl_hostinger","object":"chat.completion.chunk","created":1789550000,"model":"claude-sonnet-5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"chatcmpl_hostinger","object":"chat.completion.chunk","created":1789550000,"model":"claude-sonnet-5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":1,"total_tokens":5}}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
]
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/chat/completions").respond(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content="".join(chunks).encode(),
|
||||
)
|
||||
response: Final = litellm.completion(
|
||||
model="hostinger_router/claude-sonnet-5",
|
||||
messages=[{"role": "user", "content": "Say hello"}],
|
||||
stream=True,
|
||||
api_key="hostinger-test-key",
|
||||
)
|
||||
collected = [chunk for chunk in response]
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
body: Final = json.loads(request.content)
|
||||
assert route.call_count == 1
|
||||
assert body["stream"] is True
|
||||
assert any(chunk.choices and chunk.choices[0].delta.content == "Hi" for chunk in collected)
|
||||
|
||||
|
||||
def test_hostinger_router_responses_request():
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/responses").respond(
|
||||
200,
|
||||
json={
|
||||
"id": "resp_hostinger",
|
||||
"object": "response",
|
||||
"created_at": 1_789_550_000,
|
||||
"model": "gpt-5.2",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_hostinger",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "Hello from Hostinger Router", "annotations": []}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 4, "output_tokens": 3, "total_tokens": 7},
|
||||
},
|
||||
)
|
||||
response: Final = litellm.responses(
|
||||
model="hostinger_router/gpt-5.2",
|
||||
input="Say hello",
|
||||
api_key="hostinger-test-key",
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
body: Final = json.loads(request.content)
|
||||
assert route.call_count == 1
|
||||
assert str(request.url) == "https://router.hostinger.com/v1/responses"
|
||||
assert request.headers["authorization"] == "Bearer hostinger-test-key"
|
||||
assert body["model"] == "gpt-5.2"
|
||||
assert body["input"] == "Say hello"
|
||||
assert response.output[0].content[0].text == "Hello from Hostinger Router"
|
||||
|
||||
|
||||
EMBEDDING_RESPONSE: Final = {
|
||||
"object": "list",
|
||||
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}],
|
||||
"model": "text-embedding-3-small",
|
||||
"usage": {"prompt_tokens": 2, "total_tokens": 2},
|
||||
}
|
||||
|
||||
|
||||
def test_hostinger_router_embeddings_request():
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
response: Final = litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
body: Final = json.loads(request.content)
|
||||
assert route.call_count == 1
|
||||
assert request.headers["authorization"] == "Bearer hostinger-test-key"
|
||||
assert body["model"] == "text-embedding-3-small"
|
||||
assert response.data[0]["embedding"] == [0.1, 0.2, 0.3]
|
||||
|
||||
|
||||
def test_hostinger_router_embeddings_send_extra_headers_as_http_headers():
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
extra_headers={"X-Trace-Id": "trace-123"},
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
body: Final = json.loads(request.content)
|
||||
assert request.headers["x-trace-id"] == "trace-123"
|
||||
assert request.headers["authorization"] == "Bearer hostinger-test-key"
|
||||
assert request.headers["content-type"] == "application/json"
|
||||
assert set(body) == {"model", "input"}
|
||||
|
||||
|
||||
def test_hostinger_router_embeddings_extra_headers_replace_key_headers_of_any_case():
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
extra_headers={"authorization": "Bearer caller-key", "x-trace-id": "trace-1"},
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
assert request.headers.get_list("authorization") == ["Bearer caller-key"]
|
||||
assert request.headers.get_list("content-type") == ["application/json"]
|
||||
assert request.headers.get_list("x-trace-id") == ["trace-1"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("configured", "sent"), [(12345, "12345"), (1.5, "1.5")])
|
||||
def test_hostinger_router_embeddings_send_numeric_extra_headers_as_text(configured: int | float, sent: str):
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
extra_headers={"X-Org-Id": configured},
|
||||
)
|
||||
|
||||
assert route.calls.last.request.headers.get_list("x-org-id") == [sent]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured", [True, None, ["a", "b"]])
|
||||
def test_hostinger_router_embeddings_refuse_non_text_extra_headers(configured: object):
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
with pytest.raises(litellm.UnsupportedParamsError, match="X-Debug") as refused:
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
extra_headers={"X-Debug": configured},
|
||||
)
|
||||
|
||||
assert refused.value.status_code == 400
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured", [True, None, ["a", "b"]])
|
||||
def test_hostinger_router_embeddings_drop_non_text_extra_headers_under_drop_params(configured: object):
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
extra_headers={"X-Debug": configured, "X-Trace-Id": "trace-1"},
|
||||
drop_params=True,
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
assert "x-debug" not in request.headers
|
||||
assert request.headers.get_list("x-trace-id") == ["trace-1"]
|
||||
assert set(json.loads(request.content)) == {"model", "input"}
|
||||
|
||||
|
||||
TRANSPORT_EXTRA_HEADERS: Final = [
|
||||
("Host", "other.example"),
|
||||
("content-length", "1"),
|
||||
("Transfer-Encoding", "chunked"),
|
||||
("Connection", "close"),
|
||||
("Keep-Alive", "timeout=5"),
|
||||
("Proxy-Connection", "close"),
|
||||
("TE", "gzip"),
|
||||
("Trailer", "X-Checksum"),
|
||||
("Upgrade", "websocket"),
|
||||
("Expect", "100-continue"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "configured"), TRANSPORT_EXTRA_HEADERS)
|
||||
def test_hostinger_router_embeddings_ignore_transport_extra_headers(name: str, configured: str):
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
extra_headers={name: configured, "X-Trace-Id": "trace-1"},
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
assert request.headers.get_list("host") == ["router.hostinger.com"]
|
||||
assert request.headers.get_list("content-length") == [str(len(request.content))]
|
||||
assert "transfer-encoding" not in request.headers
|
||||
assert configured not in request.headers.get_list(name)
|
||||
assert request.headers.get_list("x-trace-id") == ["trace-1"]
|
||||
|
||||
|
||||
MALFORMED_HEADER_NAMES: Final = ["Host:evil.example", "X Note", "X-Caf\u00e9"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", MALFORMED_HEADER_NAMES)
|
||||
def test_hostinger_router_embeddings_refuse_malformed_extra_header_names(name: str):
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
with pytest.raises(litellm.UnsupportedParamsError) as refused:
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
extra_headers={name: "x"},
|
||||
)
|
||||
|
||||
assert refused.value.status_code == 400
|
||||
assert repr(name) in refused.value.message
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", MALFORMED_HEADER_NAMES)
|
||||
def test_hostinger_router_embeddings_drop_malformed_extra_header_names_under_drop_params(name: str):
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
extra_headers={name: "x", "X-Trace-Id": "trace-1"},
|
||||
drop_params=True,
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
assert request.headers.get_list("host") == ["router.hostinger.com"]
|
||||
assert request.headers.get_list("x-trace-id") == ["trace-1"]
|
||||
assert name.lower() not in request.headers.keys()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured", ["a\r\nHost: evil.example", "line\nbreak", "caf\u00e9", "a\x7fb"])
|
||||
def test_hostinger_router_embeddings_drop_extra_header_values_http_cannot_carry(configured: str):
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
extra_headers={"x-litellm-user_api_key_alias": configured, "X-Trace-Id": "trace-1"},
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
assert request.headers.get_list("host") == ["router.hostinger.com"]
|
||||
assert request.headers.get_list("x-trace-id") == ["trace-1"]
|
||||
assert "x-litellm-user_api_key_alias" not in request.headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("configured", "sent"),
|
||||
[(" x ", "x"), ("\t", ""), (" a\tb ", "a\tb"), ("env-secret\n", "env-secret"), ("\r\n x \r\n", "x")],
|
||||
)
|
||||
def test_hostinger_router_embeddings_trim_whitespace_around_extra_header_values(configured: str, sent: str):
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
extra_headers={"X-Rig": configured},
|
||||
)
|
||||
|
||||
assert route.calls.last.request.headers.get_list("x-rig") == [sent]
|
||||
|
||||
|
||||
def test_hostinger_router_embeddings_extra_headers_stay_with_their_request():
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "shared",
|
||||
"litellm_params": {
|
||||
"model": "hostinger_router/text-embedding-3-small",
|
||||
"api_key": "hostinger-test-key",
|
||||
"headers": {"X-Org-Id": "org-1"},
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
router.embedding(
|
||||
model="shared", input=["first"], extra_headers={"Authorization": "Bearer caller-a", "X-Leak": "a"}
|
||||
)
|
||||
router.embedding(model="shared", input=["second"])
|
||||
|
||||
first: Final = route.calls[0].request
|
||||
second: Final = route.calls[1].request
|
||||
assert first.headers.get_list("x-leak") == ["a"]
|
||||
assert second.headers.get_list("authorization") == ["Bearer hostinger-test-key"]
|
||||
assert "x-leak" not in second.headers
|
||||
assert second.headers.get_list("x-org-id") == ["org-1"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("param", ["headers", "extra_headers"])
|
||||
def test_hostinger_router_embeddings_send_headers_given_as_name_value_pairs(param: str):
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
**{param: [["X-Trace-Id", "trace-1"]]},
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
assert request.headers.get_list("x-trace-id") == ["trace-1"]
|
||||
assert request.headers.get_list("authorization") == ["Bearer hostinger-test-key"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("param", ["headers", "extra_headers"])
|
||||
def test_hostinger_router_embeddings_refuse_headers_that_are_not_an_object(param: str):
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
with pytest.raises(litellm.UnsupportedParamsError, match=rf"\b{param} must be a JSON object") as refused:
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
**{param: "X-Trace-Id: trace-1"},
|
||||
)
|
||||
|
||||
assert refused.value.status_code == 400
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("param", ["headers", "extra_headers"])
|
||||
def test_hostinger_router_embeddings_drop_headers_that_are_not_an_object_under_drop_params(param: str):
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
litellm.embedding(
|
||||
model="hostinger_router/text-embedding-3-small",
|
||||
input=["Say hello"],
|
||||
api_key="hostinger-test-key",
|
||||
drop_params=True,
|
||||
**{param: "X-Trace-Id: trace-1"},
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
assert "x-trace-id" not in request.headers
|
||||
assert request.headers.get_list("authorization") == ["Bearer hostinger-test-key"]
|
||||
assert set(json.loads(request.content)) == {"model", "input"}
|
||||
|
||||
|
||||
def test_hostinger_router_embeddings_never_borrow_another_providers_key(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("HOSTINGER_ROUTER_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENAI_LIKE_API_KEY", "other-provider-key")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "other-provider-key")
|
||||
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/embeddings").respond(200, json=EMBEDDING_RESPONSE)
|
||||
with pytest.raises(litellm.BadRequestError, match="Missing API Key"):
|
||||
litellm.embedding(model="hostinger_router/text-embedding-3-small", input=["Say hello"])
|
||||
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
def test_json_provider_without_embeddings_endpoint_refuses_embeddings():
|
||||
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
|
||||
|
||||
slug: Final = next(
|
||||
slug
|
||||
for slug in JSONProviderRegistry.list_providers()
|
||||
if not JSONProviderRegistry.supports_embeddings(slug) and slug not in litellm.provider_list
|
||||
)
|
||||
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
route: Final = upstream.route().respond(200, json=EMBEDDING_RESPONSE)
|
||||
with pytest.raises(litellm.BadRequestError):
|
||||
litellm.embedding(model=f"{slug}/text-embedding-3-small", input=["Say hello"], api_key="test-key")
|
||||
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hostinger_router_anthropic_messages_request(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache())
|
||||
with respx.mock() as upstream:
|
||||
route: Final = upstream.post("https://router.hostinger.com/v1/messages").respond(
|
||||
200,
|
||||
json={
|
||||
"id": "msg_hostinger",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-5",
|
||||
"content": [{"type": "text", "text": "Hello from Hostinger Router"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 4, "output_tokens": 3},
|
||||
},
|
||||
)
|
||||
response: Final = await litellm.anthropic.messages.acreate(
|
||||
model="hostinger_router/claude-sonnet-5",
|
||||
messages=[{"role": "user", "content": "Say hello"}],
|
||||
max_tokens=32,
|
||||
api_key="hostinger-test-key",
|
||||
)
|
||||
|
||||
request: Final = route.calls.last.request
|
||||
body: Final = json.loads(request.content)
|
||||
assert route.call_count == 1
|
||||
assert str(request.url) == "https://router.hostinger.com/v1/messages"
|
||||
assert request.headers["authorization"] == "Bearer hostinger-test-key"
|
||||
assert request.headers["anthropic-version"] == "2023-06-01"
|
||||
assert body["model"] == "claude-sonnet-5"
|
||||
assert body["messages"] == [{"role": "user", "content": "Say hello"}]
|
||||
assert response["content"][0]["text"] == "Hello from Hostinger Router"
|
||||
|
||||
|
||||
CHAT_COMPLETION_RESPONSE: Final = {
|
||||
"id": "chatcmpl_hostinger",
|
||||
"object": "chat.completion",
|
||||
"created": 1_789_550_000,
|
||||
"model": "gpt-5.2",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello from Hostinger Router"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7},
|
||||
}
|
||||
UNSUPPORTED_ENDPOINT: Final = {"error": {"message": "Model does not support this endpoint."}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model", ["gpt-5.2", "gpt-5-nano"])
|
||||
async def test_hostinger_router_messages_bridge_models_without_native_messages(
|
||||
model: str, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache())
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
native: Final = upstream.post("https://router.hostinger.com/v1/messages").respond(
|
||||
403, json=UNSUPPORTED_ENDPOINT
|
||||
)
|
||||
chat: Final = upstream.post("https://router.hostinger.com/v1/chat/completions").respond(
|
||||
200, json=CHAT_COMPLETION_RESPONSE
|
||||
)
|
||||
response: Final = await litellm.anthropic.messages.acreate(
|
||||
model=f"hostinger_router/{model}",
|
||||
messages=[{"role": "user", "content": "Say hello"}],
|
||||
max_tokens=32,
|
||||
api_key="hostinger-test-key",
|
||||
)
|
||||
|
||||
assert native.call_count == 0
|
||||
assert chat.call_count == 1
|
||||
assert json.loads(chat.calls.last.request.content)["model"] == model
|
||||
assert response["content"][0]["text"] == "Hello from Hostinger Router"
|
||||
|
||||
|
||||
def test_hostinger_router_responses_bridge_models_without_native_responses():
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
native: Final = upstream.post("https://router.hostinger.com/v1/responses").respond(
|
||||
403, json=UNSUPPORTED_ENDPOINT
|
||||
)
|
||||
chat: Final = upstream.post("https://router.hostinger.com/v1/chat/completions").respond(
|
||||
200, json=CHAT_COMPLETION_RESPONSE
|
||||
)
|
||||
response: Final = litellm.responses(
|
||||
model="hostinger_router/claude-sonnet-5",
|
||||
input="Say hello",
|
||||
api_key="hostinger-test-key",
|
||||
)
|
||||
|
||||
assert native.call_count == 0
|
||||
assert chat.call_count == 1
|
||||
assert json.loads(chat.calls.last.request.content)["model"] == "claude-sonnet-5"
|
||||
assert response.output[0].content[0].text == "Hello from Hostinger Router"
|
||||
|
||||
|
||||
ANTHROPIC_MESSAGE_RESPONSE: Final = {
|
||||
"id": "msg_hostinger",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-haiku-4-5",
|
||||
"content": [{"type": "text", "text": "Hello from Hostinger Router"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 4, "output_tokens": 3},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"sibling_model_info",
|
||||
[
|
||||
{"supported_endpoints": ["/v1/chat/completions", "/v1/messages", "/v1/responses"]},
|
||||
{"supported_endpoints": "/v1/messages"},
|
||||
],
|
||||
)
|
||||
async def test_hostinger_router_endpoint_opt_in_stays_with_its_own_deployment(
|
||||
sibling_model_info: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost))
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache())
|
||||
deployment_params: Final = {"model": "hostinger_router/gpt-5-nano", "api_key": "hostinger-test-key"}
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{"model_name": "opted-in", "litellm_params": deployment_params, "model_info": sibling_model_info},
|
||||
{"model_name": "plain", "litellm_params": deployment_params},
|
||||
]
|
||||
)
|
||||
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
native_messages: Final = upstream.post("https://router.hostinger.com/v1/messages").respond(
|
||||
403, json=UNSUPPORTED_ENDPOINT
|
||||
)
|
||||
native_responses: Final = upstream.post("https://router.hostinger.com/v1/responses").respond(
|
||||
403, json=UNSUPPORTED_ENDPOINT
|
||||
)
|
||||
chat: Final = upstream.post("https://router.hostinger.com/v1/chat/completions").respond(
|
||||
200, json=CHAT_COMPLETION_RESPONSE
|
||||
)
|
||||
messages_response: Final = await router.aanthropic_messages(
|
||||
model="plain", messages=[{"role": "user", "content": "Say hello"}], max_tokens=32
|
||||
)
|
||||
responses_response: Final = await router.aresponses(model="plain", input="Say hello")
|
||||
|
||||
assert native_messages.call_count == 0
|
||||
assert native_responses.call_count == 0
|
||||
assert chat.call_count == 2
|
||||
assert messages_response["content"][0]["text"] == "Hello from Hostinger Router"
|
||||
assert responses_response.output[0].content[0].text == "Hello from Hostinger Router"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hostinger_router_deployment_cache_ttl_opt_in_keeps_one_hour_ttl(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost))
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", LLMClientCache())
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "opted-in",
|
||||
"litellm_params": {
|
||||
"model": "hostinger_router/claude-haiku-4-5",
|
||||
"api_base": "https://router.hostinger.com/v1",
|
||||
"api_key": "hostinger-test-key",
|
||||
},
|
||||
"model_info": {"supported_endpoints": ["/v1/messages"], "cache_control_ttl": True},
|
||||
}
|
||||
]
|
||||
)
|
||||
one_hour_cache: Final = {"type": "ephemeral", "ttl": "1h"}
|
||||
|
||||
with respx.mock() as upstream:
|
||||
native: Final = upstream.post("https://router.hostinger.com/v1/messages").respond(
|
||||
200, json=ANTHROPIC_MESSAGE_RESPONSE
|
||||
)
|
||||
await router.aanthropic_messages(
|
||||
model="opted-in",
|
||||
messages=[
|
||||
{"role": "user", "content": [{"type": "text", "text": "Say hello", "cache_control": one_hour_cache}]}
|
||||
],
|
||||
max_tokens=32,
|
||||
)
|
||||
|
||||
body: Final = json.loads(native.calls.last.request.content)
|
||||
assert body["messages"][0]["content"][0]["cache_control"] == one_hour_cache
|
||||
BIN
ui/litellm-dashboard/public/assets/logos/hostinger.png
Normal file
BIN
ui/litellm-dashboard/public/assets/logos/hostinger.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -30,6 +30,7 @@ import googleLogo from "../../public/assets/logos/google.svg";
|
|||
import groqLogo from "../../public/assets/logos/groq.svg";
|
||||
import huggingfaceLogo from "../../public/assets/logos/huggingface.svg";
|
||||
import hyperbolicLogo from "../../public/assets/logos/hyperbolic.svg";
|
||||
import hostingerLogo from "../../public/assets/logos/hostinger.png";
|
||||
import infinityLogo from "../../public/assets/logos/infinity.png";
|
||||
import jinaLogo from "../../public/assets/logos/jina.png";
|
||||
import lambdaLogo from "../../public/assets/logos/lambda.svg";
|
||||
|
|
@ -131,6 +132,7 @@ export enum Providers {
|
|||
Hosted_Vllm = "Hosted vLLM",
|
||||
HUGGINGFACE = "Huggingface",
|
||||
HYPERBOLIC = "Hyperbolic",
|
||||
HOSTINGER_ROUTER = "Hostinger Router",
|
||||
Infinity = "Infinity",
|
||||
JinaAI = "Jina AI",
|
||||
OPENCODE_GO = "OpenCode Go",
|
||||
|
|
@ -263,6 +265,7 @@ export const provider_map: Record<string, string> = {
|
|||
Hosted_Vllm: "hosted_vllm",
|
||||
HUGGINGFACE: "huggingface",
|
||||
HYPERBOLIC: "hyperbolic",
|
||||
HOSTINGER_ROUTER: "hostinger_router",
|
||||
Infinity: "infinity",
|
||||
JinaAI: "jina_ai",
|
||||
OPENCODE_GO: "opencode_go",
|
||||
|
|
@ -389,6 +392,7 @@ export const providerLogoMap: Partial<Record<Providers, string>> = {
|
|||
[Providers.Hosted_Vllm]: vllmLogo.src,
|
||||
[Providers.HUGGINGFACE]: huggingfaceLogo.src,
|
||||
[Providers.HYPERBOLIC]: hyperbolicLogo.src,
|
||||
[Providers.HOSTINGER_ROUTER]: hostingerLogo.src,
|
||||
[Providers.Infinity]: infinityLogo.src,
|
||||
[Providers.JinaAI]: jinaLogo.src,
|
||||
[Providers.OPENCODE_GO]: opencodeLogo.src,
|
||||
|
|
@ -493,6 +497,7 @@ const providerPlaceholderMap: Partial<Record<Providers, string>> = {
|
|||
[Providers.EDENAI]: "edenai/openai/gpt-mini-latest",
|
||||
[Providers.FalAI]: "fal_ai/fal-ai/flux-pro/v1.1-ultra",
|
||||
[Providers.Google_AI_Studio]: "gemini-pro",
|
||||
[Providers.HOSTINGER_ROUTER]: "hostinger_router/claude-sonnet-5",
|
||||
[Providers.JinaAI]: "jina_ai/",
|
||||
[Providers.META]: "meta/muse-spark-1.1",
|
||||
[Providers.MICROSOFT_365_COPILOT]: "microsoft_365_copilot/chat",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue