diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 3d8fed18423..d80232bbab3 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -3323,6 +3323,28 @@ def _can_object_call_model( ): return True + # Canonical resolution: if the requested model has no manual alias but + # *provably* serves the same model as something in the request's + # allowed-models list, that's a match. Crucially, this is NOT OR-semantics + # (as with manual aliases, where the admin blessed the alias itself). It's + # AND-on-target: the target must be explicitly allowed, and the raw + # requested name being in the allowlist grants nothing. This prevents a key + # allowed ["stale-deployment-name"] from gaining access to a different + # deployment via a canonical rewrite. + if llm_router and model not in (llm_router.model_group_alias or {}): + canonical_target: Final = llm_router.resolve_canonical_model_name( + model=model, + request_team_id=team_id, + ) + if canonical_target is not None and _check_model_access_helper( + model=canonical_target, + llm_router=llm_router, + models=models, + team_model_aliases=team_model_aliases, + team_id=team_id, + ): + return True + raise ProxyException( message=f"{object_type} not allowed to access model. This {object_type} can only access models={models}. Tried to access {model}", type=ProxyErrorTypes.get_model_access_error_type_for_object(object_type=object_type), diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index b347360a939..14e0a5aba9c 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -663,6 +663,52 @@ async def route_request( elif user_model is not None or route_type == "allm_passthrough_route": return getattr(litellm, f"{route_type}")(**data) + # Last resort before failing: the requested name may be a different spelling + # of a model this router already serves (e.g. a harness sending the dated + # 'claude-haiku-4-5-20251001' at a gateway that deploys it as + # 'anthropic/claude-haiku-4-5'). Resolution is same-provider and + # identity-attested only, and runs here -- after every configured route, + # including wildcards and default_deployment, has declined -- so it can only + # turn a hard failure into a success, never re-point working traffic. + if llm_router is not None and isinstance(data.get("model"), str): + canonical_target: Final = llm_router.resolve_canonical_model_name( + model=data["model"], + request_team_id=team_id, + ) + if canonical_target is not None: + # AND-on-target: the caller must be allowed to call the *resolved* + # group. The requested spelling passing the earlier auth check is + # not enough -- without this, a key whose allowlist holds only a + # stale unserved name would ride the rewrite onto a deployment it + # was never granted. (Auth ran on the requested string before + # routing; the target group was not visible to it then.) On denial + # the rewrite is simply declined -- the request falls through to + # the same 400 it gets today, revealing nothing about the target. + target_allowed = True + if user_api_key_dict is not None: + from litellm.proxy.auth.auth_checks import can_key_call_model + + try: + await can_key_call_model( + model=canonical_target, + llm_model_list=llm_router.get_model_list(), + valid_token=user_api_key_dict, + llm_router=llm_router, + ) + except Exception: + target_allowed = False + if target_allowed: + # Preserve the client's spelling for spend logs / debugging -- + # after the rewrite it is otherwise invisible downstream. + metadata_field: Final = "litellm_metadata" if "litellm_metadata" in data else "metadata" + existing_metadata = data.get(metadata_field) + if isinstance(existing_metadata, dict): + existing_metadata.setdefault("requested_model", data["model"]) + else: + data[metadata_field] = {"requested_model": data["model"]} + data["model"] = canonical_target + return getattr(llm_router, f"{route_type}")(**data) + # if no route found then it's a bad request route_name: Final = ROUTE_ENDPOINT_MAPPING.get(route_type, route_type) raise ProxyModelNotFoundError( diff --git a/litellm/router.py b/litellm/router.py index 4a450cf3c7c..40700b16291 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -109,6 +109,12 @@ from litellm.router_utils.clientside_credential_handler import ( get_dynamic_litellm_params, is_clientside_credential, ) +from litellm.router_utils.canonical_model_resolution import ( + build_canonical_index, +) +from litellm.router_utils.canonical_model_resolution import ( + lookup as canonical_lookup, +) from litellm.router_utils.common_utils import ( _is_proxy_admin_request, filter_team_based_models, @@ -218,6 +224,7 @@ from litellm.utils import ( Rules, function_setup, get_llm_provider, + get_model_cost_mutation_generation, get_non_default_completion_params, get_secret, get_utc_datetime, @@ -626,6 +633,14 @@ class Router: # ``litellm.proxy.auth.auth_checks._is_model_cost_zero``. self._zero_cost_cache: dict[str, bool] = {} self._routing_group_rows: tuple[DeploymentTypedDict, ...] | None = None + # Lazily-built (provider, canonical_name) -> model_group index for + # ``resolve_canonical_model_name``. Invalidated alongside the model + # group info cache and on cost-map mutation (generation counter). + self._canonical_model_index: dict[tuple[str, str], str | None] | None = None + self._canonical_model_index_cost_generation: int = -1 + # Targets already announced at INFO, so a hot path logs once per target + # rather than once per request. + self._canonical_resolution_logged: set[str] = set() self._init_routing_groups(None) self.deployment_affinity_ttl_seconds = deployment_affinity_ttl_seconds @@ -10233,6 +10248,7 @@ class Router: self._cached_get_model_group_info.cache_clear() self._zero_cost_cache.clear() self._routing_group_rows = None + self._canonical_model_index = None def _invalidate_access_groups_cache(self) -> None: """Invalidate the cached access groups. @@ -10690,6 +10706,63 @@ class Router: """ return resolve_model_group_alias(self.model_group_alias, model) + def _get_canonical_model_index(self) -> dict[tuple[str, str], str | None]: + """The ``(provider, canonical_name) -> model group`` index, built on demand. + + Rebuilt when the model list changes (the index is dropped by + ``_invalidate_model_group_info_cache``) or when ``litellm.model_cost`` + mutates, since identity attestation reads it. + """ + cost_generation: Final = get_model_cost_mutation_generation() + if self._canonical_model_index is None or self._canonical_model_index_cost_generation != cost_generation: + try: + self._canonical_model_index = build_canonical_index(self.model_list) + except Exception as exc: + # Never let index construction brick a router: degrade to + # 'strict' behaviour instead. + verbose_router_logger.error("canonical-resolution: index build failed, disabling feature: %s", exc) + self._canonical_model_index = {} + self._canonical_model_index_cost_generation = cost_generation + return self._canonical_model_index + + def resolve_canonical_model_name(self, model: str, request_team_id: str | None = None) -> str | None: + """The model group that provably serves ``model`` under another spelling. + + Returns None unless every one of these holds: + - ``router_general_settings.model_name_resolution`` is ``"canonical"`` + - ``model`` is not already served (``is_recognized_model``) + - no team route, pattern/wildcard route, or ``default_deployment`` would + take the request -- those are operator-configured catch-alls and must + keep winning + - a single model group matches ``model``'s canonical identity *on the + same provider* + + Callers must treat a non-None result as authorization-relevant: the + target model group is what the key/team must be permitted to call. + """ + if self.router_general_settings.model_name_resolution != "canonical": + return None + if not model or self.is_recognized_model(model): + return None + # Operator-configured catch-alls outrank inference. + if self.default_deployment is not None or len(self.pattern_router.patterns) > 0: + return None + if request_team_id is not None and request_team_id in self.team_pattern_routers: + return None + if model in self.deployment_names: + return None + + target: Final = canonical_lookup(self._get_canonical_model_index(), model) + if target is None: + return None + # A target whose deployments have all been removed is not a live route. + if not self.model_name_to_deployment_indices.get(target): + return None + if target not in self._canonical_resolution_logged: + self._canonical_resolution_logged.add(target) + verbose_router_logger.info("canonical-resolution: '%s' -> '%s'", model, target) + return target + def _get_deployment_by_litellm_model(self, model: str) -> list: """ Get the deployment by litellm model. diff --git a/litellm/router_utils/canonical_model_resolution.py b/litellm/router_utils/canonical_model_resolution.py new file mode 100644 index 00000000000..114ccfaa15c --- /dev/null +++ b/litellm/router_utils/canonical_model_resolution.py @@ -0,0 +1,225 @@ +"""Same-provider canonical model-name resolution. + +Harnesses hardcode concrete model IDs. A client that asks for +``claude-haiku-4-5-20251001`` against a gateway that serves the very same model +under the deployment name ``anthropic/claude-haiku-4-5`` gets a 403/400 today, +even though the gateway *is* serving what was asked for. Only the spelling +differs. + +This module builds a ``canonical name -> model group`` index from the router's +own deployments so that spelling difference can be bridged. Two hard rules keep +the bridge from becoming a guess: + +1. **Identity, not similarity.** Two names are equivalent only when the model + cost map attests they are the same model: both present, same + ``litellm_provider``, same ``mode``, and identical pricing. Family/version + hops (``claude-sonnet-4-5`` -> ``claude-sonnet-5``) are never equivalences, + because that changes which model answers. +2. **Never across providers.** The requested name's inferred provider must equal + the target deployment's provider. The same weights on Bedrock, Vertex, and + the first-party API differ in credentials, data residency, quota pool, and + billing; picking between them is an operator decision, not something a + gateway should infer. Cross-provider mapping stays available through an + explicit ``model_group_alias``. + +Resolution is a last resort: callers consult it only after every existing route +(exact name, deployment id, ``model_group_alias``, routing group, team route, +wildcard/pattern route, ``default_deployment``) has declined, so a request that +succeeds today can never be re-pointed by this module. + +See also ``Router.resolve_canonical_model_name``. +""" + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Final + +import litellm +from litellm._logging import verbose_router_logger + +if TYPE_CHECKING: + from litellm.types.router import DeploymentTypedDict + +# Cost-map fields that must match exactly for two names to be called the same +# model. Pricing equality is a tripwire against false identity, not the +# definition of it -- the provider/mode checks below carry that weight. +_IDENTITY_ATTESTING_FIELDS: Final[tuple[str, ...]] = ( + "litellm_provider", + "mode", + "input_cost_per_token", + "output_cost_per_token", + "max_input_tokens", + "max_output_tokens", +) + +# Sentinel stored in the index when two distinct model groups claim the same +# canonical identity. Resolution then declines rather than silently picking a +# billing path the operator never sanctioned. +_AMBIGUOUS: Final = None + + +def _cost_map_entry(model: str) -> Mapping[str, object] | None: + """The cost-map entry for ``model``, or None when absent.""" + entry: Final = litellm.model_cost.get(model) + return entry if isinstance(entry, Mapping) else None + + +def _same_model_per_cost_map(name_a: str, name_b: str) -> bool: + """Whether the cost map attests ``name_a`` and ``name_b`` are one model. + + Both names must be present with identical provider, mode, and pricing. A + customer fine-tune or an unknown vanity name is absent from the map and so + can never be equated with anything -- which is the point. + """ + entry_a: Final = _cost_map_entry(name_a) + entry_b: Final = _cost_map_entry(name_b) + if entry_a is None or entry_b is None: + return False + return all(entry_a.get(field) == entry_b.get(field) for field in _IDENTITY_ATTESTING_FIELDS) + + +def _infer_provider(model: str) -> str | None: + """The provider LiteLLM would route ``model`` to, or None if undecidable. + + Wraps ``get_llm_provider``, which raises ``BadRequestError`` for names it + cannot place. An undecidable name simply never participates in resolution. + """ + try: + _, custom_llm_provider, _, _ = litellm.get_llm_provider(model=model) + except Exception: + return None + return custom_llm_provider or None + + +def canonicalize(model: str) -> tuple[str, str] | None: + """Reduce ``model`` to a ``(provider, canonical_name)`` identity. + + The canonical name is the model string with any LiteLLM provider-route + prefix removed (``anthropic/claude-opus-5`` -> ``claude-opus-5``), which is + LiteLLM's own routing syntax rather than part of the model's identity. The + provider is carried alongside so equality checks are always provider-scoped. + + Returns None when the provider cannot be inferred. + """ + if not model: + return None + provider: Final = _infer_provider(model) + if provider is None: + return None + # get_llm_provider returns the model with its routing prefix stripped, which + # is exactly the normalization wanted here. + try: + stripped, _, _, _ = litellm.get_llm_provider(model=model) + except Exception: + return None + return (provider, stripped or model) + + +def _undated_variants(model: str) -> tuple[str, ...]: + """Plausible dated<->undated spellings of ``model``, unvalidated. + + Purely syntactic candidate generation: every candidate is still gated by + ``_same_model_per_cost_map`` before it is treated as an equivalence, so a + coincidental date-like suffix on an unrelated model cannot create a false + match (it will not be in the cost map, or will not match on pricing). + + Only an 8-digit ``-YYYYMMDD`` suffix is considered. Deliberately narrower + than the cost-lookup heuristics in ``litellm.utils`` (which strip any + trailing ``-\\d+`` and would conflate ``gemini-1.5-pro-001`` with ``-002``): + a wrong match there mis-prices a log line, a wrong match here serves the + wrong model. + """ + parts: Final = model.rsplit("-", 1) + if len(parts) == 2 and len(parts[1]) == 8 and parts[1].isdigit(): + return (parts[0],) + return () + + +def build_canonical_index( + deployments: list["DeploymentTypedDict"], +) -> dict[tuple[str, str], str | None]: + """Map ``(provider, canonical_name) -> model group`` for ``deployments``. + + A model group is indexed only when every one of its deployments agrees on + the same canonical identity; mixed groups are skipped. When two groups claim + one identity the entry is set to ``_AMBIGUOUS`` (None) so lookups decline. + + Never raises: a malformed deployment or cost-map entry degrades to a smaller + index, never to a router that fails to boot. + """ + index: dict[tuple[str, str], str | None] = {} + group_identity: dict[str, tuple[str, str] | None] = {} + + for deployment in deployments: + try: + model_group = deployment.get("model_name") + litellm_params = deployment.get("litellm_params") or {} + underlying = litellm_params.get("model") if isinstance(litellm_params, Mapping) else None + if not isinstance(model_group, str) or not isinstance(underlying, str): + continue + + identity = canonicalize(underlying) + if model_group in group_identity and group_identity[model_group] != identity: + # Deployments in this group disagree about what they serve; the + # group cannot stand for a single canonical identity. + group_identity[model_group] = None + continue + group_identity.setdefault(model_group, identity) + except Exception as exc: # pragma: no cover - defensive + verbose_router_logger.debug("canonical-resolution: skipping deployment: %s", exc) + continue + + for model_group, identity in group_identity.items(): + if identity is None: + continue + provider, canonical_name = identity + # Index the canonical spelling plus any dated<->undated sibling the cost + # map attests is the same model. + spellings: list[str] = [canonical_name] + for candidate in _undated_variants(canonical_name): + if _same_model_per_cost_map(canonical_name, candidate): + spellings.append(candidate) + for dated, entry in litellm.model_cost.items(): + if not isinstance(entry, Mapping) or dated in spellings: + continue + if _undated_variants(dated) == (canonical_name,) and _same_model_per_cost_map(canonical_name, dated): + spellings.append(dated) + + for spelling in spellings: + key = (provider, spelling) + existing = index.get(key, "__absent__") + if existing == "__absent__": + index[key] = model_group + elif existing != model_group: + # Two groups, same identity: decline rather than choose. + index[key] = _AMBIGUOUS + verbose_router_logger.info( + "canonical-resolution: '%s' is served by more than one model group " + "(%s, %s); auto-resolution disabled for it. Add an explicit " + "model_group_alias to pick one.", + spelling, + existing, + model_group, + ) + + return index + + +def lookup( + index: Mapping[tuple[str, str], str | None], + requested_model: str, +) -> str | None: + """The model group serving ``requested_model``, or None. + + None covers every decline: unknown provider, no identity match, or an + ambiguous identity. Pure dict lookup after canonicalization -- no I/O. + """ + identity: Final = canonicalize(requested_model) + if identity is None: + return None + target: Final = index.get(identity) + if target is None: + return None + # A request already naming its serving group is not a rewrite. + if target == requested_model: + return None + return target diff --git a/litellm/types/router.py b/litellm/types/router.py index 217364c48b7..94d7930781e 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -759,6 +759,11 @@ class RouterGeneralSettings(BaseModel): pass_through_all_models: bool = Field( default=False ) # if passed a model not llm_router model list, pass through the request to litellm.acompletion/embedding + model_name_resolution: Literal["canonical", "strict"] = Field( + default="canonical" + ) # "canonical": an unknown requested model name that provably names an already-served model + # (same provider, cost-map-attested identity, e.g. dated vs undated spelling) is routed to the + # serving model group instead of failing. "strict": unknown names fail exactly as before. class RouterRateLimitErrorBasic(ValueError): diff --git a/tests/test_litellm/router_utils/test_canonical_model_resolution.py b/tests/test_litellm/router_utils/test_canonical_model_resolution.py new file mode 100644 index 00000000000..0e282af8516 --- /dev/null +++ b/tests/test_litellm/router_utils/test_canonical_model_resolution.py @@ -0,0 +1,289 @@ +"""Tests for same-provider canonical model-name resolution. + +Invariants under test (see litellm/router_utils/canonical_model_resolution.py): +- I1: resolution never re-points a request that any existing route accepts + (exact name, alias, wildcard/pattern, default_deployment, team routes). +- I2: identity only -- same provider, cost-map-attested; never a family/version + hop, never cross-provider (Bedrock/Vertex/first-party are distinct). +- I3: auth is AND-on-target -- the resolved group must itself be allowed. +- Ambiguity fails closed. +""" + +import pytest + +from litellm import Router +from litellm.proxy.auth.auth_checks import _can_object_call_model +from litellm.proxy._types import ProxyException +from litellm.router_utils.canonical_model_resolution import ( + build_canonical_index, + canonicalize, + lookup, +) + +ANTHROPIC_GROUP = "anthropic/claude-haiku-4-5" +DATED = "claude-haiku-4-5-20251001" +UNDATED = "claude-haiku-4-5" +BEDROCK_FORM = "us.anthropic.claude-haiku-4-5-20251001-v1:0" + + +@pytest.fixture +def anthropic_router() -> Router: + return Router( + model_list=[ + { + "model_name": ANTHROPIC_GROUP, + "litellm_params": {"model": "anthropic/claude-haiku-4-5", "api_key": "sk-test"}, + } + ] + ) + + +class TestCanonicalize: + def test_strips_provider_route_prefix(self): + assert canonicalize("anthropic/claude-haiku-4-5") == ("anthropic", UNDATED) + + def test_bare_dated_name_infers_provider(self): + assert canonicalize(DATED) == ("anthropic", DATED) + + def test_bedrock_form_is_bedrock_not_anthropic(self): + identity = canonicalize(BEDROCK_FORM) + assert identity is not None + assert identity[0] == "bedrock" + + def test_unknown_model_returns_none(self): + assert canonicalize("totally-made-up-model-xyz") is None + + def test_empty_returns_none(self): + assert canonicalize("") is None + + +class TestBuildIndexAndLookup: + def test_dated_and_undated_spellings_resolve(self): + index = build_canonical_index( + [ + { + "model_name": ANTHROPIC_GROUP, + "litellm_params": {"model": "anthropic/claude-haiku-4-5"}, + } + ] + ) + assert lookup(index, DATED) == ANTHROPIC_GROUP + assert lookup(index, UNDATED) == ANTHROPIC_GROUP + + def test_no_family_version_hop(self): + """claude-sonnet-5 must never resolve to a haiku group (different model).""" + index = build_canonical_index( + [ + { + "model_name": ANTHROPIC_GROUP, + "litellm_params": {"model": "anthropic/claude-haiku-4-5"}, + } + ] + ) + assert lookup(index, "claude-sonnet-5") is None + + def test_cross_provider_never_matches(self): + """An Anthropic-form request must not land on a Bedrock-only deployment, + and a Bedrock-form request must not land on a first-party deployment.""" + bedrock_only = build_canonical_index( + [ + { + "model_name": "claude-haiku-bedrock", + "litellm_params": {"model": f"bedrock/{BEDROCK_FORM}"}, + } + ] + ) + assert lookup(bedrock_only, DATED) is None + assert lookup(bedrock_only, UNDATED) is None + # Same-provider spelling still works for the Bedrock group. + assert lookup(bedrock_only, BEDROCK_FORM) == "claude-haiku-bedrock" + + anthropic_only = build_canonical_index( + [ + { + "model_name": ANTHROPIC_GROUP, + "litellm_params": {"model": "anthropic/claude-haiku-4-5"}, + } + ] + ) + assert lookup(anthropic_only, BEDROCK_FORM) is None + + def test_vertex_deployment_does_not_capture_anthropic_request(self): + index = build_canonical_index( + [ + { + "model_name": "claude-haiku-vertex", + "litellm_params": {"model": "vertex_ai/claude-haiku-4-5"}, + } + ] + ) + assert lookup(index, DATED) is None + + def test_ambiguous_identity_fails_closed(self): + """Two same-provider groups serving one model: resolution must decline.""" + index = build_canonical_index( + [ + { + "model_name": "haiku-prod", + "litellm_params": {"model": "anthropic/claude-haiku-4-5"}, + }, + { + "model_name": "haiku-experiments", + "litellm_params": {"model": "anthropic/claude-haiku-4-5"}, + }, + ] + ) + assert lookup(index, DATED) is None + assert lookup(index, UNDATED) is None + + def test_request_for_own_group_name_is_not_a_rewrite(self): + index = build_canonical_index( + [ + { + "model_name": UNDATED, # group named exactly the canonical name + "litellm_params": {"model": "anthropic/claude-haiku-4-5"}, + } + ] + ) + assert lookup(index, UNDATED) is None + + def test_malformed_deployments_are_skipped_not_fatal(self): + index = build_canonical_index( + [ + {"model_name": None, "litellm_params": {"model": "anthropic/claude-haiku-4-5"}}, + {"model_name": "ok-group", "litellm_params": None}, + { + "model_name": ANTHROPIC_GROUP, + "litellm_params": {"model": "anthropic/claude-haiku-4-5"}, + }, + ] + ) + assert lookup(index, DATED) == ANTHROPIC_GROUP + + +class TestRouterResolveCanonicalModelName: + def test_claude_code_case(self, anthropic_router: Router): + assert anthropic_router.resolve_canonical_model_name(DATED) == ANTHROPIC_GROUP + + def test_recognized_model_short_circuits(self, anthropic_router: Router): + """I1: a name the router already serves is never rewritten.""" + assert anthropic_router.resolve_canonical_model_name(ANTHROPIC_GROUP) is None + + def test_strict_mode_disables_resolution(self): + from litellm.types.router import RouterGeneralSettings + + router = Router( + model_list=[ + { + "model_name": ANTHROPIC_GROUP, + "litellm_params": {"model": "anthropic/claude-haiku-4-5", "api_key": "sk-test"}, + } + ], + router_general_settings=RouterGeneralSettings(model_name_resolution="strict"), + ) + assert router.resolve_canonical_model_name(DATED) is None + + def test_wildcard_route_outranks_resolution(self): + """I1: an operator catch-all keeps winning; resolution declines entirely.""" + router = Router( + model_list=[ + { + "model_name": ANTHROPIC_GROUP, + "litellm_params": {"model": "anthropic/claude-haiku-4-5", "api_key": "sk-test"}, + }, + { + "model_name": "openai/*", + "litellm_params": {"model": "openai/*", "api_key": "sk-test"}, + }, + ] + ) + assert router.resolve_canonical_model_name(DATED) is None + + def test_default_deployment_outranks_resolution(self): + router = Router( + model_list=[ + { + "model_name": ANTHROPIC_GROUP, + "litellm_params": {"model": "anthropic/claude-haiku-4-5", "api_key": "sk-test"}, + }, + { + "model_name": "*", + "litellm_params": {"model": "*", "api_key": "sk-test"}, + }, + ] + ) + assert router.resolve_canonical_model_name(DATED) is None + + def test_manual_alias_outranks_resolution(self): + """A model_group_alias for the same spelling wins (is_recognized_model).""" + router = Router( + model_list=[ + { + "model_name": ANTHROPIC_GROUP, + "litellm_params": {"model": "anthropic/claude-haiku-4-5", "api_key": "sk-test"}, + }, + { + "model_name": "other-group", + "litellm_params": {"model": "anthropic/claude-opus-5", "api_key": "sk-test"}, + }, + ], + model_group_alias={DATED: "other-group"}, + ) + assert router.resolve_canonical_model_name(DATED) is None + + def test_index_rebuilds_after_model_list_change(self, anthropic_router: Router): + assert anthropic_router.resolve_canonical_model_name(DATED) == ANTHROPIC_GROUP + anthropic_router.set_model_list( + [ + { + "model_name": "gpt-group", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-test"}, + } + ] + ) + assert anthropic_router.resolve_canonical_model_name(DATED) is None + + +class TestAuthAndOnTarget: + """I3: an auto-resolved request is allowed iff the TARGET is allowed.""" + + def test_target_allowed_grants_requested_spelling(self, anthropic_router: Router): + assert ( + _can_object_call_model( + model=DATED, + llm_router=anthropic_router, + models=[ANTHROPIC_GROUP], + object_type="key", + ) + is True + ) + + def test_target_not_allowed_denies(self, anthropic_router: Router): + with pytest.raises(ProxyException): + _can_object_call_model( + model=DATED, + llm_router=anthropic_router, + models=["some-other-model"], + object_type="key", + ) + + def test_unrelated_model_still_denied(self, anthropic_router: Router): + with pytest.raises(ProxyException): + _can_object_call_model( + model="claude-sonnet-5", + llm_router=anthropic_router, + models=[ANTHROPIC_GROUP], + object_type="key", + ) + + def test_unrestricted_key_unchanged(self, anthropic_router: Router): + # Empty allowlist = unrestricted; behavior must not change. + assert ( + _can_object_call_model( + model=DATED, + llm_router=anthropic_router, + models=[], + object_type="key", + ) + is True + )