This commit is contained in:
Yassin Yasser 2026-10-21 03:38:11 +00:00 • committed by GitHub
commit 9e8621fcb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 501 additions and 13 deletions

View file

@ -1423,6 +1423,7 @@ from .exceptions import (
MalformedRequestFieldError as MalformedRequestFieldError,
VectorStoreSearchError,
NotFoundError,
OpenRouterRegionNotSupportedError as OpenRouterRegionNotSupportedError,
PermissionDeniedError,
RateLimitError,
RateLimitErrorCategory,

View file

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

View file

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

View file

@ -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 = {

View file

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

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

View file

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

View file

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

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