diff --git a/litellm/__init__.py b/litellm/__init__.py index 1784deeb797..e0cf5bfa877 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1423,6 +1423,7 @@ from .exceptions import ( MalformedRequestFieldError as MalformedRequestFieldError, VectorStoreSearchError, NotFoundError, + OpenRouterRegionNotSupportedError as OpenRouterRegionNotSupportedError, PermissionDeniedError, RateLimitError, RateLimitErrorCategory, diff --git a/litellm/exceptions.py b/litellm/exceptions.py index 733f1aa72d5..a914a016509 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -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, diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 1f49cc2c4bb..98bb3c914e4 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -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, diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 85557090ae9..a9cdb8e0c54 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -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 = { diff --git a/litellm/llms/openrouter/chat/transformation.py b/litellm/llms/openrouter/chat/transformation.py index 9aaf552e79a..f0e03789a54 100644 --- a/litellm/llms/openrouter/chat/transformation.py +++ b/litellm/llms/openrouter/chat/transformation.py @@ -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. diff --git a/litellm/llms/openrouter/data_residency.py b/litellm/llms/openrouter/data_residency.py new file mode 100644 index 00000000000..8b7ec94187f --- /dev/null +++ b/litellm/llms/openrouter/data_residency.py @@ -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()) diff --git a/litellm/llms/openrouter/embedding/transformation.py b/litellm/llms/openrouter/embedding/transformation.py index 14b0e462ea7..d61f2b14b6b 100644 --- a/litellm/llms/openrouter/embedding/transformation.py +++ b/litellm/llms/openrouter/embedding/transformation.py @@ -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, diff --git a/litellm/main.py b/litellm/main.py index d647cab24e3..cd0f5406452 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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 diff --git a/tests/unit/llms/openrouter/test_openrouter_data_residency.py b/tests/unit/llms/openrouter/test_openrouter_data_residency.py new file mode 100644 index 00000000000..a70721830e5 --- /dev/null +++ b/tests/unit/llms/openrouter/test_openrouter_data_residency.py @@ -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"