mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(router): scope tag-based routing to prefixed tags only once tag_routing_prefix is configured
Once tag_routing_prefix is set, an unrelated request tag a caller happens to send (e.g. a logging or attribution tag with no routing intent) was still treated as a routing signal, causing no_deployments_with_tag_routing whenever no deployment in the group carried tags. Scopes matching, requiring, and excluding to tags the caller explicitly marked with the prefix, while key/team/project policy tags in inherited_tags keep applying regardless of prefix, since they are not caller-controlled. Fixes #39901
This commit is contained in:
parent
6c69dd0f72
commit
4a18a022e2
2 changed files with 278 additions and 1 deletions
|
|
@ -434,6 +434,49 @@ def _inherited_constraint_sets(
|
|||
return frozenset(inherited_required), frozenset(inherited_excluded)
|
||||
|
||||
|
||||
def _inherited_full_tags(inherited_tags: object, routing_prefix: str) -> frozenset[str]:
|
||||
# Every policy-contributed tag's exact, marker-preserving rewritten form --
|
||||
# used to exempt inherited tags from the request-tag noop filter below, since
|
||||
# key/team/project policy is not caller-controlled and merges its tags into
|
||||
# the same "tags" list a caller's own tags land in (see
|
||||
# litellm_pre_call_utils.py). Matching on the full tag (not just its bare
|
||||
# value, as an earlier version of this helper did) matters: bare-value-only
|
||||
# matching would let a caller smuggle an unprefixed tag past the filter just
|
||||
# by sharing its bare value with a policy tag under a different "&"/"!"
|
||||
# marker (e.g. policy requires "®ion:eu"; caller separately, unprefixed,
|
||||
# sends "!region:eu" -- bare-value matching would wrongly exempt it too).
|
||||
if not isinstance(inherited_tags, (list, tuple)):
|
||||
return frozenset()
|
||||
return frozenset(_strip_routing_prefix(inherited_tags, routing_prefix)[0])
|
||||
|
||||
|
||||
def _scope_to_confirmed_and_inherited_tags(
|
||||
rewritten_tags: tuple[str, ...],
|
||||
original_tags: Sequence[str],
|
||||
inherited_tags: object,
|
||||
routing_prefix: str,
|
||||
) -> tuple[str, ...]:
|
||||
# Once a routing prefix is configured, an unprefixed request tag (e.g. an
|
||||
# unrelated attribution tag like "user_id:234") must never be treated as a
|
||||
# routing signal at all -- not matched, not required, not excluded, and
|
||||
# never a cause of no_deployments_with_tag_routing. Only tags the caller
|
||||
# explicitly marked with the prefix participate in tag-based routing.
|
||||
# Exemption is keyed on each tag's exact, marker-preserving rewritten form
|
||||
# (not _strip_routing_prefix's own `confirmed` return, which is bare values
|
||||
# only, used by _chain_allows_fail_open's "known required tag" check for a
|
||||
# different purpose): bare-value matching would let a caller smuggle an
|
||||
# unprefixed tag past this filter just by sharing its bare value with a
|
||||
# genuinely confirmed or inherited tag under a different "&"/"!" marker.
|
||||
# _inherited_full_tags exempts key/team/project policy's own tags
|
||||
# unconditionally: policy isn't caller-controlled and was never expected to
|
||||
# carry the prefix, so it must keep applying regardless.
|
||||
confirmed_full_tags: Final = frozenset(
|
||||
rewritten for rewritten, original in zip(rewritten_tags, original_tags) if original.startswith(routing_prefix)
|
||||
)
|
||||
inherited_full_tags: Final = _inherited_full_tags(inherited_tags, routing_prefix)
|
||||
return tuple(t for t in rewritten_tags if t in confirmed_full_tags or t in inherited_full_tags)
|
||||
|
||||
|
||||
def _tag_known_to_group(
|
||||
llm_router_instance: LitellmRouter,
|
||||
model: str,
|
||||
|
|
@ -532,7 +575,14 @@ async def get_deployments_for_tag(
|
|||
# caller-declared routing directive, exempt from the "maybe foreign to this
|
||||
# group" heuristics that unprefixed tags still go through unchanged below.
|
||||
rewritten_tags, routing_confirmed = _strip_routing_prefix(request_tags or [], routing_prefix)
|
||||
required_tags, positive_tags, excluded_patterns = _split_tags(rewritten_tags)
|
||||
scoped_tags: Final = (
|
||||
_scope_to_confirmed_and_inherited_tags(
|
||||
rewritten_tags, request_tags or (), metadata.get("inherited_tags"), routing_prefix
|
||||
)
|
||||
if routing_prefix
|
||||
else rewritten_tags
|
||||
)
|
||||
required_tags, positive_tags, excluded_patterns = _split_tags(scoped_tags)
|
||||
inherited_required_set, inherited_excluded_set = _inherited_constraint_sets(
|
||||
metadata.get("inherited_tags"), routing_prefix
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3160,3 +3160,230 @@ async def test_chat_request_carrying_litellm_metadata_still_routes_on_proxy_merg
|
|||
)
|
||||
|
||||
assert deployment["model_info"]["id"] == "team-a-deployment"
|
||||
|
||||
|
||||
# --- unprefixed request tags must be a no-op once tag_routing_prefix is configured
|
||||
# (GitHub issue #39901): an unrelated attribution tag (e.g. "user_id:234") the
|
||||
# caller happens to send must never be treated as a routing signal the moment an
|
||||
# operator opts into tag_routing_prefix, since the prefix's entire point is to
|
||||
# scope which tags carry routing intent. ---
|
||||
|
||||
|
||||
def _untagged_single_deployment_router(tag_routing_prefix: str = "route:"):
|
||||
return litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "chat",
|
||||
"litellm_params": {
|
||||
"model": "gpt-4o",
|
||||
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
|
||||
},
|
||||
"model_info": {"id": "only-deployment"},
|
||||
},
|
||||
],
|
||||
enable_tag_filtering=True,
|
||||
tag_routing_prefix=tag_routing_prefix,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_unprefixed_request_tag_is_noop_when_prefix_configured():
|
||||
# No deployment in the group carries any tags at all -- a perfectly normal,
|
||||
# unadopted state. An unrelated tag the caller happens to send must not be
|
||||
# treated as a routing signal, matched, or cause no_deployments_with_tag_routing,
|
||||
# once tag_routing_prefix is configured.
|
||||
router = _untagged_single_deployment_router()
|
||||
|
||||
response = await router.acompletion(
|
||||
model="chat",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
metadata={"tags": ["user_id:234"]},
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
assert response._hidden_params["model_id"] == "only-deployment"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_no_tags_still_noop_when_prefix_configured():
|
||||
# Baseline regression guard: a request with no tags at all already worked
|
||||
# before this fix and must keep working identically after it.
|
||||
router = _untagged_single_deployment_router()
|
||||
|
||||
response = await router.acompletion(
|
||||
model="chat",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
metadata={"tags": []},
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
assert response._hidden_params["model_id"] == "only-deployment"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_prefixed_tag_still_raises_when_unresolvable():
|
||||
# A genuinely prefixed tag remains a real, trusted routing directive: if it
|
||||
# can't be resolved against any deployment, this must still raise. Proves the
|
||||
# fix scopes out unprefixed noise without disabling tag routing entirely.
|
||||
router = _untagged_single_deployment_router()
|
||||
|
||||
with pytest.raises(Exception, match='Not allowed to access model due to tags configuration\\.') as exc_info:
|
||||
await router.acompletion(
|
||||
model="chat",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
metadata={"tags": ["route:quality:nonexistent"]},
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
from litellm.types.router import RouterErrors
|
||||
|
||||
assert RouterErrors.no_deployments_with_tag_routing.value in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_unprefixed_tag_still_raises_when_no_prefix_configured():
|
||||
# Backward-compat guard: with tag_routing_prefix left at its default empty
|
||||
# string, an unprefixed tag must behave exactly as it did before this fix --
|
||||
# the new filtering only activates once a prefix is actually configured.
|
||||
router = _untagged_single_deployment_router(tag_routing_prefix="")
|
||||
|
||||
with pytest.raises(Exception, match='Not allowed to access model due to tags configuration\\.') as exc_info:
|
||||
await router.acompletion(
|
||||
model="chat",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
metadata={"tags": ["user_id:234"]},
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
from litellm.types.router import RouterErrors
|
||||
|
||||
assert RouterErrors.no_deployments_with_tag_routing.value in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_inherited_required_tag_unaffected_by_prefix_scoping():
|
||||
# An unprefixed admin/team-policy "&" requirement (merged into "tags" by
|
||||
# litellm_pre_call_utils.py and snapshotted into inherited_tags) is not
|
||||
# caller-controlled and must keep constraining deployment choice even once
|
||||
# tag_routing_prefix is configured -- the new scoping applies only to what the
|
||||
# caller themselves declared, not to what policy already contributed.
|
||||
router = _eu_region_router()
|
||||
router.update_settings(tag_routing_prefix="route:")
|
||||
|
||||
response = await router.acompletion(
|
||||
model="chat",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
metadata={"tags": ["®ion:eu"], "inherited_tags": ["®ion:eu"]},
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
assert response._hidden_params["model_id"] == "eu-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_inherited_excluded_tag_unaffected_by_prefix_scoping():
|
||||
# Same guarantee for an unprefixed admin/team-policy "!" exclusion, exercised
|
||||
# without exhausting the pool (no fail-open floor involved) -- this proves the
|
||||
# primary (non-fail-open) candidate computation itself still honors it, not
|
||||
# just the trusted-only fail-open fallback.
|
||||
router = _eu_region_router()
|
||||
router.update_settings(tag_routing_prefix="route:")
|
||||
|
||||
response = await router.acompletion(
|
||||
model="chat",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
metadata={"tags": ["!region:us"], "inherited_tags": ["!region:us"]},
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
assert response._hidden_params["model_id"] == "eu-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_foreign_tag_ignored_alongside_inherited_constraint_when_prefix_configured():
|
||||
# Realistic combined scenario: the caller's own unrelated attribution tag
|
||||
# rides alongside an inherited policy requirement in the same request. The
|
||||
# foreign tag must be ignored while the inherited requirement still routes
|
||||
# correctly. Deliberately not using _eu_region_router() here: both of its
|
||||
# deployments set allow_fail_open=True, so a leaked "user_id:234" positive
|
||||
# tag would still resolve to "eu-1" via the fail-open floor's own
|
||||
# inherited-tags intersection, masking a regression in the filter itself.
|
||||
# No allow_fail_open here means a leaked foreign tag has no safety net to
|
||||
# hide behind -- it must show up as an outright raise, not a lucky match.
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "chat",
|
||||
"litellm_params": {
|
||||
"model": "gpt-4o",
|
||||
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
|
||||
"tags": ["region:eu"],
|
||||
},
|
||||
"model_info": {"id": "eu-1"},
|
||||
},
|
||||
{
|
||||
"model_name": "chat",
|
||||
"litellm_params": {
|
||||
"model": "gpt-4o-mini",
|
||||
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
|
||||
"tags": ["region:us"],
|
||||
},
|
||||
"model_info": {"id": "us-1"},
|
||||
},
|
||||
],
|
||||
enable_tag_filtering=True,
|
||||
)
|
||||
router.update_settings(tag_routing_prefix="route:")
|
||||
|
||||
response = await router.acompletion(
|
||||
model="chat",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
metadata={"tags": ["®ion:eu", "user_id:234"], "inherited_tags": ["®ion:eu"]},
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
assert response._hidden_params["model_id"] == "eu-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
async def test_unprefixed_tag_cannot_piggyback_via_bare_value_collision_with_confirmed_tag():
|
||||
# Regression: exemption from the noop filter must be keyed on a tag's exact,
|
||||
# marker-preserving form, not just its bare value. A caller sending both a
|
||||
# genuinely confirmed positive tag ("route:region:eu") and an unrelated,
|
||||
# unprefixed exclusion sharing the same bare value ("!region:eu") must not
|
||||
# have that exclusion smuggled through just because "region:eu" happens to
|
||||
# be confirmed under a different marker -- the exclusion is caller noise and
|
||||
# must stay inert, same as any other unprefixed tag.
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "chat",
|
||||
"litellm_params": {
|
||||
"model": "gpt-4o",
|
||||
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
|
||||
"tags": ["region:eu"],
|
||||
},
|
||||
"model_info": {"id": "eu-1"},
|
||||
},
|
||||
{
|
||||
"model_name": "chat",
|
||||
"litellm_params": {
|
||||
"model": "gpt-4o-mini",
|
||||
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
|
||||
"tags": ["region:us"],
|
||||
},
|
||||
"model_info": {"id": "us-1"},
|
||||
},
|
||||
],
|
||||
enable_tag_filtering=True,
|
||||
tag_routing_prefix="route:",
|
||||
)
|
||||
|
||||
response = await router.acompletion(
|
||||
model="chat",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
metadata={"tags": ["route:region:eu", "!region:eu"]},
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
assert response._hidden_params["model_id"] == "eu-1"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue