mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +00:00
Merge b35689d8f8 into 8d6b8dab3d
This commit is contained in:
commit
9e8621fcb1
9 changed files with 501 additions and 13 deletions
|
|
@ -1423,6 +1423,7 @@ from .exceptions import (
|
|||
MalformedRequestFieldError as MalformedRequestFieldError,
|
||||
VectorStoreSearchError,
|
||||
NotFoundError,
|
||||
OpenRouterRegionNotSupportedError as OpenRouterRegionNotSupportedError,
|
||||
PermissionDeniedError,
|
||||
RateLimitError,
|
||||
RateLimitErrorCategory,
|
||||
|
|
|
|||
|
|
@ -222,6 +222,35 @@ class NotFoundError(openai.NotFoundError):
|
|||
return _message
|
||||
|
||||
|
||||
class OpenRouterRegionNotSupportedError(NotFoundError):
|
||||
"""
|
||||
Raised when OpenRouter returns HTTP 404 indicating no in-region endpoint exists
|
||||
for the requested model in the specified data region (e.g. 'us' or 'eu').
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
model: str,
|
||||
llm_provider: str = "openrouter",
|
||||
response: httpx.Response | None = None, # pyright: ignore[reportUnknownMemberType,reportUnknownParameterType] # httpx.Response type is untyped
|
||||
litellm_debug_info: str | None = None,
|
||||
max_retries: int | None = None,
|
||||
num_retries: int | None = None,
|
||||
region: str | None = None,
|
||||
) -> None:
|
||||
self.region = region
|
||||
super().__init__( # pyright: ignore[reportUnknownMemberType] # NotFoundError.__init__ is untyped
|
||||
message=message,
|
||||
model=model,
|
||||
llm_provider=llm_provider,
|
||||
response=response,
|
||||
litellm_debug_info=litellm_debug_info,
|
||||
max_retries=max_retries,
|
||||
num_retries=num_retries,
|
||||
)
|
||||
|
||||
|
||||
class BadRequestError(openai.BadRequestError):
|
||||
def __init__(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from litellm.litellm_core_utils.bug_report import (
|
|||
)
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping
|
||||
from litellm.litellm_core_utils.secret_redaction import redact_string as redact_secret_string
|
||||
from litellm.llms.openrouter.data_residency import infer_openrouter_data_residency
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
from ..exceptions import (
|
||||
|
|
@ -30,6 +31,7 @@ from ..exceptions import (
|
|||
ContextWindowExceededError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
OpenRouterRegionNotSupportedError,
|
||||
PaymentRequiredError,
|
||||
PermissionDeniedError,
|
||||
RateLimitError,
|
||||
|
|
@ -2232,6 +2234,25 @@ def _map_openrouter_exception(
|
|||
litellm_debug_info=extra_information,
|
||||
)
|
||||
elif original_exception.status_code == 404:
|
||||
if "supporting your data region" in error_str.lower():
|
||||
response_obj: Final[object] = getattr(original_exception, "response", None)
|
||||
resp_url: Final[object] = getattr(response_obj, "url", None) if response_obj is not None else None
|
||||
inferred_region: Final[str | None] = (
|
||||
infer_openrouter_data_residency(
|
||||
custom_llm_provider="openrouter",
|
||||
api_base=str(resp_url),
|
||||
)
|
||||
if resp_url is not None
|
||||
else None
|
||||
)
|
||||
raise OpenRouterRegionNotSupportedError(
|
||||
message=f"OpenRouterRegionNotSupportedError: {exception_provider} - {error_str}",
|
||||
model=model,
|
||||
llm_provider=custom_llm_provider,
|
||||
response=getattr(original_exception, "response", None),
|
||||
litellm_debug_info=extra_information,
|
||||
region=inferred_region,
|
||||
)
|
||||
raise NotFoundError(
|
||||
message=f"NotFoundError: {exception_provider} - {error_str}",
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from pydantic import TypeAdapter, ValidationError
|
|||
from litellm.constants import CONTROL_OPTIONS_KEY
|
||||
from litellm.litellm_core_utils.core_helpers import normalize_drop_params
|
||||
from litellm.llms.openai.data_residency import infer_openai_data_residency
|
||||
from litellm.llms.openrouter.data_residency import infer_openrouter_data_residency
|
||||
from litellm.types.litellm_params import MAX_CONTROL_INT_DIGITS, ControlOptions
|
||||
from litellm.types.router import CustomPricingLiteLLMParams
|
||||
from litellm.types.workload_identity import ANTHROPIC_WIF_KWARGS_KEYS, OPENAI_WIF_KWARGS_KEYS
|
||||
|
|
@ -216,7 +217,9 @@ def get_litellm_params(
|
|||
if litellm_trace_id is None:
|
||||
litellm_trace_id = _meta.get("trace_id") or _meta.get("session_id")
|
||||
|
||||
data_residency: Final[str | None] = infer_openai_data_residency(custom_llm_provider, api_base)
|
||||
data_residency: Final[str | None] = infer_openai_data_residency(
|
||||
custom_llm_provider, api_base
|
||||
) or infer_openrouter_data_residency(custom_llm_provider, api_base)
|
||||
|
||||
# Build base dict with explicit parameters (always included)
|
||||
litellm_params: Final = {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from litellm.types.utils import ModelResponse, ModelResponseStream
|
|||
|
||||
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
|
||||
from ..common_utils import OpenRouterException
|
||||
from ..data_residency import resolve_openrouter_api_base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
|
@ -46,6 +47,35 @@ def openrouter_model_supports_cache_control(model: str) -> bool:
|
|||
|
||||
|
||||
class OpenrouterConfig(OpenAIGPTConfig):
|
||||
region: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
region: str | None = None,
|
||||
) -> None:
|
||||
self.region = region
|
||||
super().__init__() # pyright: ignore[reportUnknownMemberType] # OpenAIGPTConfig.__init__ is untyped
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
api_key: str | None,
|
||||
model: str,
|
||||
optional_params: dict, # pyright: ignore[reportMissingTypeArgument,reportUnknownParameterType] # mutable-ok: overrides OpenAIGPTConfig.get_complete_url signature
|
||||
litellm_params: dict, # pyright: ignore[reportMissingTypeArgument,reportUnknownParameterType] # mutable-ok: overrides OpenAIGPTConfig.get_complete_url signature
|
||||
stream: bool | None = None,
|
||||
) -> str:
|
||||
resolved_base: Final = resolve_openrouter_api_base(
|
||||
api_base=api_base,
|
||||
region=self.region,
|
||||
litellm_params=litellm_params, # pyright: ignore[reportUnknownArgumentType] # overrides untyped OpenAIGPTConfig signature
|
||||
optional_params=optional_params, # pyright: ignore[reportUnknownArgumentType] # overrides untyped OpenAIGPTConfig signature
|
||||
).rstrip("/")
|
||||
endpoint: Final = "chat/completions"
|
||||
if endpoint in resolved_base:
|
||||
return resolved_base
|
||||
return f"{resolved_base}/{endpoint}"
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list:
|
||||
"""
|
||||
Include reasoning parameters regardless of model-map capabilities.
|
||||
|
|
|
|||
131
litellm/llms/openrouter/data_residency.py
Normal file
131
litellm/llms/openrouter/data_residency.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""
|
||||
OpenRouter In-Region Routing and Data Residency Helpers.
|
||||
|
||||
OpenRouter provides dedicated regional endpoints for end-to-end data residency:
|
||||
- EU: https://eu.openrouter.ai/api/v1
|
||||
- US: https://us.openrouter.ai/api/v1
|
||||
- Global default: https://openrouter.ai/api/v1
|
||||
|
||||
Docs: https://openrouter.ai/docs/guides/features/in-region-routing
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import litellm
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
||||
# Normalized region names mapped to their dedicated OpenRouter base URLs
|
||||
_OPENROUTER_REGIONAL_URLS: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"eu": "https://eu.openrouter.ai/api/v1",
|
||||
"europe": "https://eu.openrouter.ai/api/v1",
|
||||
"us": "https://us.openrouter.ai/api/v1",
|
||||
"usa": "https://us.openrouter.ai/api/v1",
|
||||
"united states": "https://us.openrouter.ai/api/v1",
|
||||
}
|
||||
)
|
||||
|
||||
# OpenRouter regional hostnames mapped to canonical data residency codes ("eu", "us")
|
||||
_OPENROUTER_REGIONAL_HOSTNAMES: Final[Mapping[str, str]] = MappingProxyType(
|
||||
{
|
||||
"eu.openrouter.ai": "eu",
|
||||
"us.openrouter.ai": "us",
|
||||
}
|
||||
)
|
||||
|
||||
_DEFAULT_OPENROUTER_API_BASE: Final[str] = "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def _extract_region_value(params: Mapping[str, object] | None) -> str | None:
|
||||
if params is None:
|
||||
return None
|
||||
for key in ("region", "region_name", "data_residency"):
|
||||
val: object = params.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_openrouter_api_base(
|
||||
api_base: str | None = None,
|
||||
region: str | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
optional_params: Mapping[str, object] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the OpenRouter API base URL based on custom api_base, explicit region, or environment.
|
||||
|
||||
Precedence:
|
||||
1. If `api_base` is passed and is a custom URL (not the default global openrouter.ai URL), use it.
|
||||
2. If `region` is provided (via param, litellm_params, optional_params, or environment), map to the regional endpoint.
|
||||
3. Fallback to `api_base or litellm.api_base or OPENROUTER_API_BASE or default global URL`.
|
||||
"""
|
||||
# Check if a custom api_base was provided that is not the default global endpoint
|
||||
cleaned_base: Final[str | None] = api_base.rstrip("/").lower() if api_base else None
|
||||
is_custom_api_base: Final[bool] = bool(
|
||||
cleaned_base
|
||||
and cleaned_base not in ("https://openrouter.ai/api/v1", "https://openrouter.ai", "http://openrouter.ai")
|
||||
)
|
||||
|
||||
if is_custom_api_base and api_base:
|
||||
return api_base
|
||||
|
||||
# Check for region in litellm_params, optional_params, explicit region argument, config, or environment
|
||||
region_from_params: Final[str | None] = _extract_region_value(litellm_params)
|
||||
region_from_optional: Final[str | None] = _extract_region_value(optional_params)
|
||||
|
||||
litellm_region: Final[object] = getattr(litellm, "openrouter_region", None)
|
||||
litellm_data_residency: Final[object] = getattr(litellm, "openrouter_data_residency", None)
|
||||
attr_region: Final[str | None] = (
|
||||
litellm_region
|
||||
if isinstance(litellm_region, str)
|
||||
else (litellm_data_residency if isinstance(litellm_data_residency, str) else None)
|
||||
)
|
||||
|
||||
explicit_region: Final[str | None] = region.strip() if isinstance(region, str) and region.strip() else None
|
||||
resolved_region: Final[str | None] = region_from_params or region_from_optional or explicit_region or attr_region
|
||||
|
||||
if resolved_region is not None:
|
||||
normalized_region: Final[str] = resolved_region.strip().lower()
|
||||
if normalized_region in ("global", "default"):
|
||||
return _DEFAULT_OPENROUTER_API_BASE
|
||||
if normalized_region in _OPENROUTER_REGIONAL_URLS:
|
||||
return _OPENROUTER_REGIONAL_URLS[normalized_region]
|
||||
if normalized_region.startswith(("eu-", "eu_", "europe-", "europe_")):
|
||||
return "https://eu.openrouter.ai/api/v1"
|
||||
if normalized_region.startswith(("us-", "us_", "usa-", "usa_")):
|
||||
return "https://us.openrouter.ai/api/v1"
|
||||
raise ValueError(
|
||||
f"Unsupported OpenRouter region: '{resolved_region}'. Supported regions are 'eu', 'us', 'global'."
|
||||
)
|
||||
|
||||
fallback_api_base: Final[str | None] = api_base or litellm.api_base or get_secret_str("OPENROUTER_API_BASE")
|
||||
return fallback_api_base or _DEFAULT_OPENROUTER_API_BASE
|
||||
|
||||
|
||||
def infer_openrouter_data_residency(
|
||||
custom_llm_provider: str | None,
|
||||
api_base: str | None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Derive the OpenRouter data-residency region from an api_base URL.
|
||||
|
||||
Returns "eu" for eu.openrouter.ai, "us" for us.openrouter.ai, and None for
|
||||
the default global host or non-OpenRouter URLs.
|
||||
"""
|
||||
if custom_llm_provider != "openrouter" or not api_base:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed_url: Final = urlparse(api_base)
|
||||
host: Final[str | None] = parsed_url.hostname
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if host is None:
|
||||
return None
|
||||
|
||||
return _OPENROUTER_REGIONAL_HOSTNAMES.get(host.lower())
|
||||
|
|
@ -17,6 +17,7 @@ from litellm.types.utils import EmbeddingResponse
|
|||
from litellm.utils import convert_to_model_response_object
|
||||
|
||||
from ..common_utils import OpenRouterException
|
||||
from ..data_residency import resolve_openrouter_api_base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
|
|
@ -84,15 +85,10 @@ class OpenrouterEmbeddingConfig(BaseEmbeddingConfig):
|
|||
"""
|
||||
Get the complete URL for OpenRouter Embedding API endpoint.
|
||||
"""
|
||||
# api_base is already set to https://openrouter.ai/api/v1 in main.py
|
||||
# Remove trailing slashes
|
||||
if api_base:
|
||||
api_base = api_base.rstrip("/")
|
||||
else:
|
||||
api_base = "https://openrouter.ai/api/v1"
|
||||
resolved_base: Final = resolve_openrouter_api_base(api_base=api_base).rstrip("/")
|
||||
|
||||
# Return the embeddings endpoint
|
||||
return f"{api_base}/embeddings"
|
||||
return f"{resolved_base}/embeddings"
|
||||
|
||||
def transform_embedding_request(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -130,6 +130,10 @@ from litellm.llms.cohere.common_utils import CohereModelInfo
|
|||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler, http2_enabled
|
||||
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
|
||||
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
|
||||
from litellm.llms.openrouter.data_residency import (
|
||||
infer_openrouter_data_residency,
|
||||
resolve_openrouter_api_base,
|
||||
)
|
||||
from litellm.llms.vertex_ai.common_utils import (
|
||||
VertexAIModelRoute,
|
||||
get_vertex_ai_model_route,
|
||||
|
|
@ -3483,7 +3487,17 @@ def _complete_openrouter(ctx: CompletionDispatchContext) -> _CompletionDispatchR
|
|||
stream: Final = ctx.stream
|
||||
timeout: Final = ctx.timeout
|
||||
|
||||
api_base = api_base or litellm.api_base or get_secret_str("OPENROUTER_API_BASE") or "https://openrouter.ai/api/v1"
|
||||
api_base = resolve_openrouter_api_base( # rebind-ok: regional endpoint resolution for openrouter
|
||||
api_base=api_base,
|
||||
litellm_params=litellm_params,
|
||||
optional_params=optional_params, # pyright: ignore[reportUnknownArgumentType] # untyped optional_params in dispatch context
|
||||
)
|
||||
|
||||
litellm_params["api_base"] = api_base
|
||||
litellm_params["data_residency"] = infer_openrouter_data_residency("openrouter", api_base)
|
||||
|
||||
## Load Config
|
||||
config: Final = litellm.OpenrouterConfig.get_config()
|
||||
|
||||
api_key = (
|
||||
api_key
|
||||
|
|
@ -3507,8 +3521,6 @@ def _complete_openrouter(ctx: CompletionDispatchContext) -> _CompletionDispatchR
|
|||
|
||||
headers = openrouter_headers
|
||||
|
||||
## Load Config
|
||||
config: Final = litellm.OpenrouterConfig.get_config()
|
||||
for k, v in _provider_config_items(config):
|
||||
if k == "extra_body":
|
||||
# we use openai 'extra_body' to pass openrouter specific params - transforms, route, models
|
||||
|
|
@ -6990,10 +7002,15 @@ def embedding(
|
|||
headers=headers,
|
||||
)
|
||||
elif custom_llm_provider == "openrouter":
|
||||
api_base = (
|
||||
api_base or litellm.api_base or get_secret_str("OPENROUTER_API_BASE") or "https://openrouter.ai/api/v1"
|
||||
api_base = resolve_openrouter_api_base( # rebind-ok: regional endpoint resolution for openrouter
|
||||
api_base=api_base,
|
||||
litellm_params=litellm_params_dict, # pyright: ignore[reportUnknownArgumentType] # untyped litellm_params in embedding
|
||||
optional_params=optional_params, # pyright: ignore[reportUnknownArgumentType] # untyped optional_params in embedding
|
||||
)
|
||||
|
||||
litellm_params_dict["api_base"] = api_base
|
||||
litellm_params_dict["data_residency"] = infer_openrouter_data_residency("openrouter", api_base)
|
||||
|
||||
api_key = (
|
||||
api_key
|
||||
or litellm.api_key
|
||||
|
|
|
|||
260
tests/unit/llms/openrouter/test_openrouter_data_residency.py
Normal file
260
tests/unit/llms/openrouter/test_openrouter_data_residency.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
"""Tests for OpenRouter in-region routing, data residency inference, and error handling."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.exceptions import NotFoundError, OpenRouterRegionNotSupportedError
|
||||
from litellm.litellm_core_utils.exception_mapping_utils import _map_openrouter_exception
|
||||
from litellm.litellm_core_utils.get_litellm_params import get_litellm_params
|
||||
from litellm.llms.openrouter.chat.transformation import OpenrouterConfig
|
||||
from litellm.llms.openrouter.data_residency import (
|
||||
infer_openrouter_data_residency,
|
||||
resolve_openrouter_api_base,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_base, region, expected",
|
||||
[
|
||||
(None, "eu", "https://eu.openrouter.ai/api/v1"),
|
||||
(None, "EU", "https://eu.openrouter.ai/api/v1"),
|
||||
(None, "europe", "https://eu.openrouter.ai/api/v1"),
|
||||
(None, "eu-west-1", "https://eu.openrouter.ai/api/v1"),
|
||||
(None, "eu-central-1", "https://eu.openrouter.ai/api/v1"),
|
||||
(None, "europe-west3", "https://eu.openrouter.ai/api/v1"),
|
||||
(None, "us", "https://us.openrouter.ai/api/v1"),
|
||||
(None, "US", "https://us.openrouter.ai/api/v1"),
|
||||
(None, "usa", "https://us.openrouter.ai/api/v1"),
|
||||
(None, "united states", "https://us.openrouter.ai/api/v1"),
|
||||
(None, "us-east-1", "https://us.openrouter.ai/api/v1"),
|
||||
(None, "us-west-2", "https://us.openrouter.ai/api/v1"),
|
||||
(None, "global", "https://openrouter.ai/api/v1"),
|
||||
(None, "default", "https://openrouter.ai/api/v1"),
|
||||
(None, None, "https://openrouter.ai/api/v1"),
|
||||
("https://openrouter.ai/api/v1", "eu", "https://eu.openrouter.ai/api/v1"),
|
||||
("https://openrouter.ai/api/v1", "us", "https://us.openrouter.ai/api/v1"),
|
||||
("https://custom.openrouter.proxy/v1", "eu", "https://custom.openrouter.proxy/v1"),
|
||||
("https://custom.openrouter.proxy/v1", "apac", "https://custom.openrouter.proxy/v1"),
|
||||
("https://custom.openrouter.proxy/v1", None, "https://custom.openrouter.proxy/v1"),
|
||||
],
|
||||
)
|
||||
def test_resolve_openrouter_api_base(api_base, region, expected):
|
||||
assert resolve_openrouter_api_base(api_base=api_base, region=region) == expected
|
||||
|
||||
|
||||
def test_resolve_openrouter_api_base_litellm_attrs(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "openrouter_region", "eu", raising=False)
|
||||
assert resolve_openrouter_api_base() == "https://eu.openrouter.ai/api/v1"
|
||||
monkeypatch.delattr(litellm, "openrouter_region", raising=False)
|
||||
monkeypatch.setattr(litellm, "openrouter_data_residency", "us", raising=False)
|
||||
assert resolve_openrouter_api_base() == "https://us.openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def test_resolve_openrouter_api_base_unsupported_region_raises():
|
||||
with pytest.raises(ValueError, match="Unsupported OpenRouter region"):
|
||||
resolve_openrouter_api_base(region="apac")
|
||||
with pytest.raises(ValueError, match="Unsupported OpenRouter region"):
|
||||
resolve_openrouter_api_base(region="collector.attacker.example/")
|
||||
|
||||
|
||||
def test_resolve_openrouter_api_base_from_dict_params():
|
||||
assert resolve_openrouter_api_base(litellm_params={"region": "eu"}) == "https://eu.openrouter.ai/api/v1"
|
||||
assert resolve_openrouter_api_base(litellm_params={"region_name": "us"}) == "https://us.openrouter.ai/api/v1"
|
||||
assert resolve_openrouter_api_base(litellm_params={"data_residency": "eu"}) == "https://eu.openrouter.ai/api/v1"
|
||||
assert resolve_openrouter_api_base(optional_params={"region": "us"}) == "https://us.openrouter.ai/api/v1"
|
||||
assert resolve_openrouter_api_base(optional_params={"region_name": "eu"}) == "https://eu.openrouter.ai/api/v1"
|
||||
assert resolve_openrouter_api_base(optional_params={"data_residency": "us"}) == "https://us.openrouter.ai/api/v1"
|
||||
assert (
|
||||
resolve_openrouter_api_base(api_base="https://openrouter.ai", region="eu") == "https://eu.openrouter.ai/api/v1"
|
||||
)
|
||||
assert (
|
||||
resolve_openrouter_api_base(api_base="http://openrouter.ai", region="us") == "https://us.openrouter.ai/api/v1"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_base, expected",
|
||||
[
|
||||
("https://eu.openrouter.ai/api/v1", "eu"),
|
||||
("https://eu.openrouter.ai", "eu"),
|
||||
("https://us.openrouter.ai/api/v1", "us"),
|
||||
("https://us.openrouter.ai", "us"),
|
||||
("https://EU.openrouter.ai/api/v1", "eu"),
|
||||
("https://openrouter.ai/api/v1", None),
|
||||
("https://openrouter.ai", None),
|
||||
("https://example.com/v1", None),
|
||||
("", None),
|
||||
(None, None),
|
||||
("invalid-url", None),
|
||||
],
|
||||
)
|
||||
def test_infer_openrouter_data_residency(api_base, expected):
|
||||
assert infer_openrouter_data_residency("openrouter", api_base) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom_llm_provider", [None, "openai", "anthropic", "azure", "bedrock"])
|
||||
def test_infer_openrouter_data_residency_non_openrouter(custom_llm_provider):
|
||||
assert infer_openrouter_data_residency(custom_llm_provider, "https://eu.openrouter.ai/api/v1") is None
|
||||
|
||||
|
||||
def test_get_litellm_params_infers_openrouter_data_residency():
|
||||
params_eu = get_litellm_params(
|
||||
custom_llm_provider="openrouter",
|
||||
api_base="https://eu.openrouter.ai/api/v1",
|
||||
)
|
||||
assert params_eu["data_residency"] == "eu"
|
||||
|
||||
params_us = get_litellm_params(
|
||||
custom_llm_provider="openrouter",
|
||||
api_base="https://us.openrouter.ai/api/v1",
|
||||
)
|
||||
assert params_us["data_residency"] == "us"
|
||||
|
||||
params_global = get_litellm_params(
|
||||
custom_llm_provider="openrouter",
|
||||
api_base="https://openrouter.ai/api/v1",
|
||||
)
|
||||
assert params_global["data_residency"] is None
|
||||
|
||||
|
||||
def test_openrouter_config_region():
|
||||
cfg = OpenrouterConfig(region="eu")
|
||||
assert cfg.region == "eu"
|
||||
|
||||
|
||||
def test_openrouter_chat_config_get_complete_url():
|
||||
cfg = OpenrouterConfig(region="eu")
|
||||
url = cfg.get_complete_url(
|
||||
api_base=None,
|
||||
api_key="sk-test",
|
||||
model="anthropic/claude-3-opus",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
assert url == "https://eu.openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
# Per-request parameter overrides config
|
||||
url_us = cfg.get_complete_url(
|
||||
api_base=None,
|
||||
api_key="sk-test",
|
||||
model="anthropic/claude-3-opus",
|
||||
optional_params={"region": "us"},
|
||||
litellm_params={},
|
||||
)
|
||||
assert url_us == "https://us.openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
|
||||
def test_openrouter_region_not_supported_error_mapping():
|
||||
"""Verify that OpenRouter 404 with regional error raises OpenRouterRegionNotSupportedError."""
|
||||
fake_response = httpx.Response(
|
||||
status_code=404,
|
||||
request=httpx.Request("POST", "https://eu.openrouter.ai/api/v1/chat/completions"),
|
||||
)
|
||||
fake_exc = httpx.HTTPStatusError(
|
||||
message="404 Not Found",
|
||||
request=fake_response.request,
|
||||
response=fake_response,
|
||||
)
|
||||
fake_exc.status_code = 404
|
||||
|
||||
# 1. Regional 404 message -> OpenRouterRegionNotSupportedError
|
||||
error_msg = '{"error": {"message": "No endpoints found supporting your data region", "code": 404}}'
|
||||
with pytest.raises(OpenRouterRegionNotSupportedError) as exc_info:
|
||||
_map_openrouter_exception(
|
||||
model="openrouter/anthropic/claude-sonnet-4",
|
||||
original_exception=fake_exc,
|
||||
custom_llm_provider="openrouter",
|
||||
error_str=error_msg,
|
||||
exception_type="NotFoundError",
|
||||
exception_provider="openrouter",
|
||||
extra_information="",
|
||||
)
|
||||
|
||||
# Ensure it's also an instance of NotFoundError for backward compatibility
|
||||
assert isinstance(exc_info.value, NotFoundError)
|
||||
assert isinstance(exc_info.value, OpenRouterRegionNotSupportedError)
|
||||
assert exc_info.value.region == "eu"
|
||||
|
||||
# 2. Standard 404 (model not found) -> standard NotFoundError
|
||||
standard_error_msg = '{"error": {"message": "Model not found", "code": 404}}'
|
||||
with pytest.raises(NotFoundError) as exc_info_standard:
|
||||
_map_openrouter_exception(
|
||||
model="openrouter/non-existent-model",
|
||||
original_exception=fake_exc,
|
||||
custom_llm_provider="openrouter",
|
||||
error_str=standard_error_msg,
|
||||
exception_type="NotFoundError",
|
||||
exception_provider="openrouter",
|
||||
extra_information="",
|
||||
)
|
||||
assert not isinstance(exc_info_standard.value, OpenRouterRegionNotSupportedError)
|
||||
|
||||
# 3. 404 with no response object or no URL
|
||||
fake_exc_no_resp = Exception("404 Not Found")
|
||||
fake_exc_no_resp.status_code = 404
|
||||
with pytest.raises(OpenRouterRegionNotSupportedError) as exc_no_resp:
|
||||
_map_openrouter_exception(
|
||||
model="openrouter/anthropic/claude-sonnet-4",
|
||||
original_exception=fake_exc_no_resp,
|
||||
custom_llm_provider="openrouter",
|
||||
error_str=error_msg,
|
||||
exception_type="NotFoundError",
|
||||
exception_provider="openrouter",
|
||||
extra_information="",
|
||||
)
|
||||
assert exc_no_resp.value.region is None
|
||||
|
||||
# 4. Exception class default initialization
|
||||
custom_err = OpenRouterRegionNotSupportedError(message="error", model="test-model")
|
||||
assert custom_err.llm_provider == "openrouter"
|
||||
assert custom_err.region is None
|
||||
|
||||
|
||||
def test_openrouter_chat_config_endpoint_already_in_api_base():
|
||||
cfg = OpenrouterConfig(region="eu")
|
||||
url = cfg.get_complete_url(
|
||||
api_base="https://eu.openrouter.ai/api/v1/chat/completions",
|
||||
api_key="sk-test",
|
||||
model="anthropic/claude-3-opus",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
assert url == "https://eu.openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
|
||||
def test_openrouter_completion_with_region_parameter(monkeypatch):
|
||||
"""Test that completion() resolves regional api_base and tracks data_residency."""
|
||||
captured_api_base = None
|
||||
captured_litellm_params = None
|
||||
|
||||
def mock_completion(*args, **kwargs):
|
||||
nonlocal captured_api_base, captured_litellm_params
|
||||
captured_api_base = kwargs.get("api_base")
|
||||
captured_litellm_params = kwargs.get("litellm_params")
|
||||
return litellm.ModelResponse(
|
||||
choices=[
|
||||
litellm.Choices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=litellm.Message(content="Hello from EU", role="assistant"),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.main.base_llm_http_handler.completion",
|
||||
mock_completion,
|
||||
)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-mock-key")
|
||||
|
||||
resp = litellm.completion(
|
||||
model="openrouter/anthropic/claude-sonnet-4",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
region="eu",
|
||||
)
|
||||
assert captured_api_base == "https://eu.openrouter.ai/api/v1"
|
||||
assert captured_litellm_params is not None
|
||||
assert captured_litellm_params.get("api_base") == "https://eu.openrouter.ai/api/v1"
|
||||
assert captured_litellm_params.get("data_residency") == "eu"
|
||||
assert resp.choices[0].message.content == "Hello from EU"
|
||||
Loading…
Add table
Reference in a new issue