feat: same-provider canonical model-name resolution

Implement Feature A: when a request names a model using one spelling (e.g.
dated 'claude-haiku-4-5-20251001') that the gateway serves under a different
spelling (e.g. 'anthropic/claude-haiku-4-5'), route the request to the serving
deployment instead of failing with a 403/400. Resolution only fires after all
existing routes (exact name, alias, wildcard, default_deployment, team routes)
have declined, so a request that succeeds today is never re-pointed (I1).

Identity is enforced strictly: same provider (never cross-provider like Bedrock
to Vertex), same model per cost-map attestation (never family/version hops like
claude-sonnet-4-5 to claude-sonnet-5) (I2).

Auth is AND-on-target: the caller must be allowed to call the resolved target
group; the requested spelling's presence in an allowlist alone grants nothing,
preventing privilege escalation via stale spellings (I3).

Observability: requested name preserved in request metadata, resolution logged
at INFO per (requested, target) pair, Prometheus counter for cardinality.

Changes:
- litellm/router_utils/canonical_model_resolution.py: new module with
  canonicalize, build index, lookup functions.
- litellm/router.py: Router.resolve_canonical_model_name(), index caching +
  invalidation, config flag model_name_resolution.
- litellm/types/router.py: RouterGeneralSettings.model_name_resolution field.
- litellm/proxy/route_llm_request.py: resolve hook before 400, re-auth on
  target, metadata stamping.
- litellm/proxy/auth/auth_checks.py: canonical lookup in _can_object_call_model
  with AND-on-target semantics.
- tests/test_litellm/router_utils/test_canonical_model_resolution.py: 23
  property tests covering I1–I4, cross-provider block, ambiguity fail-closed,
  auth semantics.

Defaults to 'canonical' (on); users can set model_name_resolution: strict in
router_settings to opt out entirely.

Fixes the Claude Code case: dated Haiku requests now resolve across harness
versions without client-side env vars.

Follow-up: once operator data shows a single provider deployment of all
Anthropic models, Feature B (cross-deployment resolution when only 1 provider
exists) becomes safe to implement under similar guards.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Abhimanyu Kapur 2026-08-15 08:13:19 -07:00
parent d70cc14981
commit 55c499100e
6 changed files with 660 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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