diff --git a/litellm/constants.py b/litellm/constants.py index 28e7b4a7fd7..7d99c28deb3 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1165,6 +1165,7 @@ openai_compatible_providers: Final[list[str]] = [ "cortecs", "scx-ai", "prism", + "hostinger_router", "sail", "reka", "streamlake", diff --git a/litellm/llms/databricks/embed/handler.py b/litellm/llms/databricks/embed/handler.py index 8b2fa768968..05807a118c3 100644 --- a/litellm/llms/databricks/embed/handler.py +++ b/litellm/llms/databricks/embed/handler.py @@ -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, ) diff --git a/litellm/llms/openai_like/README.md b/litellm/llms/openai_like/README.md index c56f58b24c9..fb00b07c986 100644 --- a/litellm/llms/openai_like/README.md +++ b/litellm/llms/openai_like/README.md @@ -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` diff --git a/litellm/llms/openai_like/embedding/handler.py b/litellm/llms/openai_like/embedding/handler.py index 77b6b673707..540feeca6e7 100644 --- a/litellm/llms/openai_like/embedding/handler.py +++ b/litellm/llms/openai_like/embedding/handler.py @@ -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), ) diff --git a/litellm/llms/openai_like/json_loader.py b/litellm/llms/openai_like/json_loader.py index 6e99b9e7fec..658e4176f22 100644 --- a/litellm/llms/openai_like/json_loader.py +++ b/litellm/llms/openai_like/json_loader.py @@ -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""" diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index ad47b334ed4..a71a9c3bb9b 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -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", diff --git a/litellm/main.py b/litellm/main.py index 2320d1a3e01..21ae9657d68 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index cbe502e5cae..7eede4a395f 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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", diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index d33e61026df..700a35edb06 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -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", diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 01a822c5d93..044b8a1de6e 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -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", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 2f6918d0744..24f4acaf5df 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4683,6 +4683,7 @@ class LlmProviders(str, Enum): PRISM = "prism" OPENINFER = "openinfer" INFERENCEAPIS = "inferenceapis" + HOSTINGER_ROUTER = "hostinger_router" ZEROGPU = "zerogpu" DARKBLOOM = "darkbloom" META = "meta" diff --git a/litellm/utils.py b/litellm/utils.py index 8fe3fd84fbd..f19fa0dbb34 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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)() diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index cbe502e5cae..7eede4a395f 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index d33e61026df..700a35edb06 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -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", diff --git a/tests/unit/llms/openai_like/test_hostinger_router_provider.py b/tests/unit/llms/openai_like/test_hostinger_router_provider.py new file mode 100644 index 00000000000..cbc16c8567a --- /dev/null +++ b/tests/unit/llms/openai_like/test_hostinger_router_provider.py @@ -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 diff --git a/ui/litellm-dashboard/public/assets/logos/hostinger.png b/ui/litellm-dashboard/public/assets/logos/hostinger.png new file mode 100644 index 00000000000..9ae3f4bcca1 Binary files /dev/null and b/ui/litellm-dashboard/public/assets/logos/hostinger.png differ diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index a8491f656a0..a8879eb570b 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -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 = { 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> = { [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> = { [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",