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:
Algirdas Č. 2026-10-31 09:38:45 +02:00 • committed by GitHub
parent a7b66dd8b9
commit 57090e94a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1047 additions and 25 deletions

View file

@ -1165,6 +1165,7 @@ openai_compatible_providers: Final[list[str]] = [
"cortecs",
"scx-ai",
"prism",
"hostinger_router",
"sail",
"reka",
"streamlake",

View file

@ -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,
)

View file

@ -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`

View file

@ -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),
)

View file

@ -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"""

View file

@ -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",

View file

@ -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,

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -4683,6 +4683,7 @@ class LlmProviders(str, Enum):
PRISM = "prism"
OPENINFER = "openinfer"
INFERENCEAPIS = "inferenceapis"
HOSTINGER_ROUTER = "hostinger_router"
ZEROGPU = "zerogpu"
DARKBLOOM = "darkbloom"
META = "meta"

View file

@ -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)()

View file

@ -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",

View file

@ -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",

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -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",