Skip to content

feat(router): tag routing denylist support via ! prefix - #31680

Closed
deepanshululla wants to merge 17 commits into
BerriAI:litellm_oss_stagingfrom
deepanshululla:litellm_tag_routing_negation
Closed

feat(router): tag routing denylist support via ! prefix#31680
deepanshululla wants to merge 17 commits into
BerriAI:litellm_oss_stagingfrom
deepanshululla:litellm_tag_routing_negation

Conversation

@deepanshululla

@deepanshululla deepanshululla commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Fixes #31676

Docs: BerriAI/litellm-docs#437

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Run the proxy with a config that tags deployments by provider, then send requests with negation tags:

# /tmp/tag-negation-test.yaml
model_list:
  - model_name: chat
    litellm_params:
      model: openai/gpt-4o-mini
      api_key: fake-key
      mock_response: "hello from openai"
      tags: ["provider:openai"]
  - model_name: chat
    litellm_params:
      model: anthropic/claude-haiku-4-5-20251001
      api_key: fake-key
      mock_response: "hello from anthropic"
      tags: ["provider:anthropic"]
  - model_name: chat
    litellm_params:
      model: openai/gpt-4o
      api_key: fake-key
      mock_response: "hello from vertex"
      tags: ["provider:vertex"]
router_settings:
  enable_tag_filtering: true
general_settings:
  master_key: sk-test-1234
python litellm/proxy/proxy_cli.py --config /tmp/tag-negation-test.yaml --port 4000

Exclude Anthropic, remaining pool (openai + vertex) is used:

curl http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-test-1234" \
  -d '{"model":"chat","messages":[{"role":"user","content":"hi"}],"metadata":{"tags":["!provider:anthropic"]}}'
# -> "hello from openai" or "hello from vertex" (not anthropic)

Exclude both anthropic and openai with two negation tags, only vertex remains:

curl http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-test-1234" \
  -d '{"model":"chat","messages":[{"role":"user","content":"hi"}],"metadata":{"tags":["!provider:anthropic","!provider:openai"]}}'
# -> "hello from vertex"

Exclude all providers -> error:

curl http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-test-1234" \
  -d '{"model":"chat","messages":[{"role":"user","content":"hi"}],"metadata":{"tags":["!provider:anthropic","!provider:openai","!provider:vertex"]}}'
# -> 400: Not allowed to access model due to tags configuration

Fallback chain fires when primary group is banned (config with fallbacks defined):

curl http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-test-1234" \
  -d '{"model":"primary-chat","messages":[{"role":"user","content":"hi"}],"metadata":{"tags":["!provider:anthropic"]}}'
# -> falls through to fallback-chat (provider:openai)

Type

New Feature

Changes

Adds ! prefix negation to x-litellm-tags for tag-based routing. Callers can now exclude deployments by exact tag value without needing to enumerate every allowed alternative.

Changes in litellm/router_strategy/tag_based_routing.py:

  • _split_tags(tags): partitions the request tag list into positive tags (unchanged, passed to existing inclusion logic) and excluded literals (strings after ! prefix). A bare ! with no suffix is ignored.
  • _exclude_deployments(deployments, excluded_set): new helper; filters deployments whose tags intersect the excluded set. Called before tag or regex matching so negation always runs first.
  • _ban_only_base_pool(deployments): new helper; returns the default-tagged pool when one exists, otherwise all deployments. Used by the ban-only path to mirror untagged-request semantics and prevent callers from using negation tags to escape the default pool.
  • get_deployments_for_tag: builds a frozenset of excluded literals and calls _exclude_deployments before any inclusion logic. When only negation tags are present (ban-only), routes within the default pool if one exists. Raises no_deployments_with_tag_routing when the exclusion filter empties the pool.

Matching is exact and literal (frozenset intersection). No regex engine is involved for client-supplied tags, so there is no ReDoS risk. A tag like !provider:(anthropic|openai) only excludes a deployment tagged exactly provider:(anthropic|openai) — it does not act as a regex alternation. To exclude multiple providers, send separate tags: ["!provider:anthropic", "!provider:openai"]. Operator-configured tag_regex in deployment config is unaffected.

Interaction with tag_regex: negation exclusion runs before regex matching. If a deployment carries both a plain tags list and tag_regex, and its plain tag is negated, it is excluded before regex matching runs. If that deployment was the only one with tag_regex, has_tag_filter becomes False and the ban-only path fires instead.

Fallback chains work automatically: get_deployments_for_tag is called on each routing hop, so a banned primary group triggers the existing fallback mechanism without any extra wiring.

18 new unit and integration tests cover: _split_tags edge cases (including bare ! skip), single and multiple literal negation tags, mixed positive+negation, literal-only semantics (no partial match, no regex interpretation), ban-only exhausting all candidates, ban-only confined to the default pool (routing bypass regression), untagged deployments being kept, fallback chain firing when primary is banned, the full fallback chain exhausting with an error, and two tag_regex + negation interaction cases.

Deepanshu added 4 commits June 29, 2026 22:23
Requests can now exclude deployments by prefixing any tag with `!`.
`!provider:anthropic` removes all Anthropic deployments before routing;
negated tags are treated as regex so `!provider:(anthropic|openai)` works
in one tag. Positive tags and negation compose: `paid,!provider:anthropic`
first strips Anthropic, then applies the `paid` inclusion filter. Invalid
regex patterns are logged and skipped rather than erroring the request.
Verifies that negated tags propagate correctly across fallback hops:
primary group fully banned -> fallback fires and succeeds; all groups
banned -> error raised after the chain is exhausted.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


Deepanshu seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@deepanshululla

Copy link
Copy Markdown
Contributor Author

Curl verification (proxy running on localhost:4000 with mock_response)

Config 1: three providers tagged by provider name

model_list:
  - model_name: chat
    litellm_params:
      model: openai/gpt-4o-mini
      api_key: fake-key
      mock_response: "hello from openai"
      tags: ["provider:openai", "family:gpt-4o"]
  - model_name: chat
    litellm_params:
      model: anthropic/claude-haiku-4-5-20251001
      api_key: fake-key
      mock_response: "hello from anthropic"
      tags: ["provider:anthropic", "family:claude-haiku"]
  - model_name: chat
    litellm_params:
      model: openai/gpt-4o
      api_key: fake-key
      mock_response: "hello from vertex"
      tags: ["provider:vertex", "inference:vertex"]
router_settings:
  enable_tag_filtering: true

Test 1: exclude anthropic, remainder (openai + vertex) are both valid

$ curl ... -d '{"model":"chat","messages":[...],"metadata":{"tags":["!provider:anthropic"]}}'
content: hello from vertex   # round-robin picks from remaining non-anthropic pool

Test 2: regex alternation excludes both anthropic and openai, only vertex remains

$ curl ... -d '{"model":"chat","messages":[...],"metadata":{"tags":["!provider:(anthropic|openai)"]}}'
content: hello from vertex
content: hello from vertex
content: hello from vertex

Test 3: backward compat - no tags, all deployments available

$ curl ... -d '{"model":"chat","messages":[...]}'
content: hello from vertex
content: hello from openai
content: hello from vertex

Test 4: exclude all providers -> error

$ curl ... -d '{"model":"chat","messages":[...],"metadata":{"tags":["!provider:(anthropic|openai|vertex)"]}}'
error: Not allowed to access model due to tags configuration. Passed model=chat and tags=['!provider:(anthropic|openai|vertex)']

Config 2: fallback chains

model_list:
  - model_name: primary-chat
    litellm_params: {model: anthropic/..., tags: ["provider:anthropic"], mock_response: "hello from anthropic (primary)"}
  - model_name: fallback-chat
    litellm_params: {model: openai/..., tags: ["provider:openai"], mock_response: "hello from openai (fallback)"}
  - model_name: all-banned-primary
    litellm_params: {model: anthropic/..., tags: ["provider:anthropic"], mock_response: "hello from anthropic (all-banned primary)"}
  - model_name: all-banned-fallback
    litellm_params: {model: anthropic/..., tags: ["provider:anthropic"], mock_response: "hello from anthropic (all-banned fallback)"}
router_settings:
  enable_tag_filtering: true
  fallbacks:
    - {"primary-chat": ["fallback-chat"]}
    - {"all-banned-primary": ["all-banned-fallback"]}

Test 5: ban anthropic on primary-chat -> fallback fires to openai

$ curl ... -d '{"model":"primary-chat","messages":[...],"metadata":{"tags":["!provider:anthropic"]}}'
content: hello from openai (fallback)

Test 6: ban anthropic on all-banned-primary (both primary and fallback are anthropic) -> exhausted

$ curl ... -d '{"model":"all-banned-primary","messages":[...],"metadata":{"tags":["!provider:anthropic"]}}'
error: Not allowed to access model due to tags configuration. Passed model=all-banned-primary and tags=['!provider:anthropic']

@deepanshululla

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds denylist-style tag routing with !-prefixed tags. The main changes are:

  • Split request tags into positive tags and negation regex patterns
  • Filter excluded deployments before normal tag inclusion runs
  • Return remaining candidates for negation-only requests
  • Add tests for negation, mixed filters, invalid regexes, and fallback routing

Confidence Score: 4/5

The change is mostly isolated to tag-based routing, but literal bang-prefixed tags need attention before merge.

Focused coverage exists for the new denylist behavior, and the remaining concern is a concrete compatibility regression in existing exact-match tag routing semantics.

litellm/router_strategy/tag_based_routing.py

T-Rex T-Rex Logs

What T-Rex did

  • Ran a focused Python repro against get_deployments_for_tag with literal '!paid' filtering to validate tag routing; the deployment with the literal tag was not selected and a ValueError: Not allowed to access model due to tags configuration was raised instead.
  • Compared routing outcomes for negation tag scenarios; before the changes, the base head returned HTTP 500 or no deployments for ban-only or mixed scenarios, and after the changes, the head returns 200 OK with model_ids for negation scenarios and 500 or 200 for other cases as described.
  • Compared base and head validation artifacts; the base run showed Scenario A failed with ValueError no_deployments_with_tag_routing and Scenario B exhausted, while the head run showed Scenario A as success (openai-fallback) and Scenario B as error with no_deployments_with_tag_routing, with exit codes improving from 1 to 0.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "style: ruff format and remove unused imp..." | Re-trigger Greptile

Comment on lines +126 to +128
positive = [t for t in tags if not t.startswith("!")]
excluded_patterns = [
p for tag in tags if tag.startswith("!") if (p := _compile_negation_pattern(tag[1:])) is not None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Preserve literal bang tags
_split_tags now treats every request tag starting with ! as a negation, so an existing deployment/request pair using a literal tag like !paid stops matching under the existing enable_tag_filtering setting. This changes current routing behavior without a user-controlled migration path; reserve negation behind an explicit opt-in or escaping scheme so current !-prefixed tags keep their old exact-match behavior.

Rule Used: What: avoid backwards-incompatible changes without... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Repro: focused helper-level script for literal bang tag routing

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: script output showing literal !paid deployment excluded with no_deployments_with_tag_routing error

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7d2eb24. Negation patterns are now treated as exact literal strings; the implementation uses frozenset intersection instead of regex. A bare ! with nothing after it is skipped. This also means a legacy deployment tagged !paid is unaffected — the exclusion only fires when a request tag exactly matches a deployment tag after stripping the ! prefix.

Comment thread litellm/router_strategy/tag_based_routing.py Outdated
@veria-ai

veria-ai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR adds tag-based routing denylist support to the router using a ! prefix, allowing requests to exclude deployments associated with specific tags. The touched routing logic handles tags supplied through request metadata or tag headers when selecting candidate deployments.

There is still one open security issue in the tag-routing behavior: ban-only tag requests can expand routing beyond the default/untagged pool and reach deployments the caller did not positively select or appear entitled to use. One previously reported issue has already been addressed, but this remaining authorization/routing bypass keeps the PR at a high current risk until deny-only requests are constrained appropriately.

Open issues (1)

Fixed/addressed: 1 · PR risk: 7/10

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds negated tag routing for router deployment selection. The main changes are:

  • Splits request tags into positive tags and !-prefixed exclusions.
  • Filters excluded deployments before normal tag matching runs.
  • Supports ban-only requests and fallback-chain behavior.
  • Adds tests for negation, invalid patterns, untagged deployments, and fallbacks.

Confidence Score: 4/5

Merge should wait for the request-controlled regex behavior in tag negation to be bounded or made literal.

The change is well scoped and has meaningful tests, but runtime evidence confirms that crafted negation tags can stall routing work.

litellm/router_strategy/tag_based_routing.py

T-Rex T-Rex Logs

What T-Rex did

  • I reproduced the tag negation regex slowdown by running a focused Python harness against get_deployments_for_tag with enable_tag_filtering enabled.
  • I compared before and after routing behavior for negation tag handling and validated the new routing: requests with '!provider:anthropic' route to anthropic+openai and to untagged deployments returning 200 OK, while a full ban case yields a 500 ValueError.
  • I captured and compared trex run logs showing the command, cwd, exit code, and model_id results for all three scenarios, noting the after-state model_id outcomes such as paid-openai and openai-fallback along with a ValueError when appropriate.
  • I validated the tag-splitting changes and invalid regex handling, confirming positive tags and exclusions are produced, and that invalid patterns are logged and skipped, with routing succeeding to openai-deployment-1 in the mock setup.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (2): Last reviewed commit: "style: ruff format and remove unused imp..." | Re-trigger Greptile

Comment on lines +117 to +119
def _compile_negation_pattern(pattern_str: str) -> Optional[re.Pattern[str]]:
try:
return re.compile(pattern_str)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Avoid request regex
pattern_str comes from request metadata tags, so re.compile(pattern_str) lets callers send expensive patterns that run against every deployment tag in the router path. A tag with nested quantifiers can monopolize CPU during routing; keep negation literal, use a safe pattern subset, or use matching with a timeout.

Artifacts

Repro: focused runtime harness for tag negation regex slowdown

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Stack trace captured during the T-Rex run

  • Keeps the raw stack trace available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7d2eb24 — same fix as the veria-ai comment above. Exact literal matching via frozenset, no regex.

Deepanshu added 8 commits June 29, 2026 22:39
… of regex

Negation tags come from client-supplied request metadata, so compiling
them as regex patterns opens a ReDoS vector. Exact tag membership
testing (frozenset intersection) is O(1), safe, and sufficient for the
primary use case: !provider:anthropic excludes any deployment whose tag
list contains that exact string.

Replacing the regex alternation test with two separate literal negation
tags covers the same multi-provider exclusion scenario without regex.
Adding test_negation_literal_only_no_partial_match to document that
!provider:anthropic does NOT match provider:anthropic-haiku.
Break two long f-strings in tag_based_routing.py to stay under the 120-char
limit. Remove redundant local import and unused response assignment in the
test file. Update tag_routing.md to include full existing live-page content
plus the new negation section.
ban_only = bool(excluded_set) and not has_tag_filter

if ban_only:
return _require_candidates(candidates, model, request_tags)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Tag routing bypass

An authenticated caller can send a ban-only tag such as metadata.tags: ["!default"] or x-litellm-tags: !default and this branch returns all non-banned candidates instead of preserving the untagged/default routing behavior. In a config with a default deployment plus tagged team/paid/provider deployments, that lets a caller with no positive entitlement tag route traffic to deployments outside the untagged pool; apply the denylist to the default pool for ban-only requests, or require at least one positive tag before expanding beyond defaults.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 36ffa2a. Added _ban_only_base_pool helper that mirrors untagged-request semantics: if any deployment carries the default tag, ban-only requests are restricted to that pool before the exclusion set is applied; otherwise all healthy deployments are the base. This means !default on a config with a default + paid deployment raises no_deployments_with_tag_routing instead of routing to the paid deployment. Two regression tests added.

Deepanshu added 4 commits June 29, 2026 23:53
Without an upstream authentication layer, any client can spoof User-Agent
and be routed to unintended deployments.
A caller sending only !-prefixed tags (e.g. !default) was routed to all
non-banned candidates, allowing them to reach tagged deployments outside
the untagged/default pool. Ban-only requests now use the same base pool
as untagged requests: default-tagged deployments if any exist, otherwise
all healthy deployments.

Adds _ban_only_base_pool helper and two regression tests.
…erals

!provider:(anthropic|openai) must not exclude deployments tagged
provider:anthropic or provider:openai; the frozenset intersection
only matches exact strings, so both models must remain reachable.
@deepanshululla

Copy link
Copy Markdown
Contributor Author

Correction to Test 2 in the verification above: ["!provider:(anthropic|openai)"] does not work as a regex alternation. Negation matching is exact and literal (frozenset intersection), so that tag only excludes a deployment tagged exactly provider:(anthropic|openai). The vertex responses in that test were coincidental round-robin, not regex exclusion.

To exclude multiple providers, send separate negation tags: ["!provider:anthropic", "!provider:openai"] (as shown in Test 2 of the proof-of-fix in the PR description).

Added a regression test test_negation_regex_pattern_treated_as_literal in 25690d8 that verifies this: with !provider:(anthropic|openai), both provider:anthropic and provider:openai deployments remain reachable.

Two cases: (1) negation removes a plain-tagged deployment and the
surviving tag_regex deployment is still matched by User-Agent;
(2) negation removes the only tag_regex deployment, leaving no regex
candidates, so has_tag_filter=False, ban_only fires, and the remaining
plain-tagged deployment is returned.
mateo-berri added a commit that referenced this pull request Jun 30, 2026
Adds `!` prefix negation to tag-based routing so callers can exclude deployments by exact tag value without enumerating every allowed alternative. `!provider:anthropic` removes all deployments tagged exactly `provider:anthropic` before routing, and positive and negation tags compose. Matching is exact literal membership (frozenset intersection), so there is no regex or ReDoS surface for client-supplied tags. Ban-only requests that carry only negation tags stay within the default pool, mirroring untagged-request semantics so callers can't use negation to escape it. Fallback chains keep working because get_deployments_for_tag runs on each routing hop

Copy of #31680; implementation credit to @deepanshululla

Co-authored-by: deepanshululla <15312873+deepanshululla@users.noreply.github.com>
@mateo-berri

Copy link
Copy Markdown
Contributor

this has been merged via #31728. Thank you for the contribution!

tiannianzhu pushed a commit to tiannianzhu/litellm that referenced this pull request Jul 3, 2026
Adds `!` prefix negation to tag-based routing so callers can exclude deployments by exact tag value without enumerating every allowed alternative. `!provider:anthropic` removes all deployments tagged exactly `provider:anthropic` before routing, and positive and negation tags compose. Matching is exact literal membership (frozenset intersection), so there is no regex or ReDoS surface for client-supplied tags. Ban-only requests that carry only negation tags stay within the default pool, mirroring untagged-request semantics so callers can't use negation to escape it. Fallback chains keep working because get_deployments_for_tag runs on each routing hop

Copy of BerriAI#31680; implementation credit to @deepanshululla

Co-authored-by: deepanshululla <15312873+deepanshululla@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants