From 57090e94a4050dc684155a635d8a437020ded774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Algirdas=20=C4=8C=2E?= Date: Sat, 31 Oct 2026 09:38:45 +0200 Subject: [PATCH] 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> --- litellm/constants.py | 1 + litellm/llms/databricks/embed/handler.py | 2 + litellm/llms/openai_like/README.md | 14 + litellm/llms/openai_like/embedding/handler.py | 77 +- litellm/llms/openai_like/json_loader.py | 25 +- litellm/llms/openai_like/providers.json | 12 +- litellm/main.py | 43 +- ...odel_prices_and_context_window_backup.json | 51 ++ .../provider_endpoints_support_backup.json | 26 + .../provider_create_fields.json | 28 + litellm/types/utils.py | 1 + litellm/utils.py | 4 +- model_prices_and_context_window.json | 51 ++ provider_endpoints_support.json | 26 + .../test_hostinger_router_provider.py | 706 ++++++++++++++++++ .../public/assets/logos/hostinger.png | Bin 0 -> 16604 bytes .../src/components/provider_info_helpers.tsx | 5 + 17 files changed, 1047 insertions(+), 25 deletions(-) create mode 100644 tests/unit/llms/openai_like/test_hostinger_router_provider.py create mode 100644 ui/litellm-dashboard/public/assets/logos/hostinger.png 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 0000000000000000000000000000000000000000..9ae3f4bcca1b0a3d2fa65a23064017950f4926eb GIT binary patch literal 16604 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

BhjkFB91D_ z+WXcauH(_uaNMvE_V;0VbQ-eN^{sy#g?zZH>TWf>-~J#v?#(EeK(G%@6YIl-tRIhn z{B4Im%_j_EX1HixKr!6-)L&=<@{h!)R$HgH`@mGX$5RRH87At3KIfm2T}6QClgkcl z0{ghbVkFj_~RI0p8Z@4-4kcOBr6Dk zJtA$N=<%^SYFZ_f3tj(ifX*Fq!#r#*kIqRW9RNBjaHD^LPAgvGXcV;JX7qirlkQSl z83pPB031H{l^C(L5hQNeh=+&ksiTV#@`RAdg5<4x%j1J}z4) z49ZBXaqJs^@~$g+0OJxp!J-G3-U27sVqRSksmj`$+Y72vT0E+uD*pqFlbOdG@C`(k z(QYvFCrv%?&1_le)lwE7O1IM(D(t=ohVR(jv%++x?txvt~yOH;OPaps+9u>&1DLLUO?kAsMuX0ZT~yhvs-Zj$VvbR^Zz7Z z*0@c{Nj8#JH|ad)fx$XJ;>R1dYyz?lDRZPvB-eehgVMV+g&da!2h#sJq2N|!^O0kd zO|on48!au(iB^}Rnq{DK0^5jELELI=xFX(o_q_iJ2#jRffiGH^P~N7rhR9x&r%0P}Lap_s}KyPdiL{j-+U5OzCdV&RX9%bCE zvCzbT9&!jzaep0O^DI8!X@y{>dfj4L_1L{g-4Y;?g%j2fRu=;FJ~q#Wph)ayMa}|c>20vuqs~&2qa+IB4ZsH2A@EG|GgLhX1 z8xHEGEefGsTy9U#@sT<{d4sJ)UTZww!AE7cn=@1<~-?VcRp!M3j4PI7eF>|t=-^Cavy`b%iSFd!i4FzS7ydUs cRiPI+wI&}sUR&O5fKHQvD@qkSe&zT70bxfGz5oCK literal 0 HcmV?d00001 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",