Skip to content

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

Merged
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_tag_routing_negation
Jun 30, 2026
Merged

feat(router): tag routing denylist support via ! prefix#31728
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_tag_routing_negation

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Fixes #31676

Copy of #31680; the implementation is by @deepanshululla. This branch re-pushes that change to a litellm_-prefixed branch on the main repo so the full CircleCI pipeline runs against it. All credit for the feature goes to the original author

Linear ticket

N/A

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • 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. mock_response is used so the proof exercises the routing decision (which deployment is selected) without an upstream call, since the selected model_id is what this feature changes

# /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; the 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" (never 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 every provider, the pool is empty and the request errors:

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

Type

New Feature

Changes

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

In litellm/router_strategy/tag_based_routing.py:

  • _split_tags(tags) partitions the request tag list into positive tags (unchanged, fed to the existing inclusion logic) and excluded literals (the string after a ! prefix). A bare ! with no suffix is ignored
  • _exclude_deployments(deployments, excluded_set) filters out deployments whose tags intersect the excluded set. It runs before tag or regex matching so negation always applies first
  • _ban_only_base_pool(deployments) returns the default-tagged pool when one exists, otherwise all deployments. The ban-only path uses it to mirror untagged-request semantics so callers can't use negation tags to escape the default pool
  • get_deployments_for_tag builds a frozenset of excluded literals, excludes before any inclusion logic, and for ban-only requests routes within the default pool. It raises no_deployments_with_tag_routing when the exclusion filter empties the pool

Matching is exact literal membership via frozenset intersection, so there is no regex engine for client-supplied tags and no ReDoS surface. !provider:(anthropic|openai) only excludes a deployment tagged exactly provider:(anthropic|openai); to exclude multiple providers, send separate tags ["!provider:anthropic", "!provider:openai"]. Operator-configured tag_regex in deployment config is unaffected, and negation exclusion runs before regex matching so a deployment whose plain tag is negated drops out before its tag_regex is considered

Fallback chains work without extra wiring because get_deployments_for_tag runs on each routing hop, so a banned primary group triggers the existing fallback mechanism

The change adds 18 unit and integration tests covering _split_tags edge cases, single and multiple literal negation tags, mixed positive plus negation, literal-only semantics (no partial match, no regex interpretation), ban-only exhausting all candidates, ban-only confined to the default pool, untagged deployments being kept, the fallback chain firing when the primary is banned, the full chain exhausting with an error, and two tag_regex plus negation interaction cases

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

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

@codspeed-hq

codspeed-hq Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 30 untouched benchmarks


Comparing litellm_tag_routing_negation (8894dbf) with litellm_internal_staging (1eb7122)

Open in CodSpeed

@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 !-prefix negation to tag-based routing so callers can exclude deployments by tag without enumerating all allowed alternatives. Exclusion is exact-literal frozenset intersection, so there is no regex engine over client-supplied strings and no ReDoS surface.

  • _split_tags partitions request tags into positive (existing inclusion logic) and excluded (the string after !); bare ! is ignored.
  • _exclude_deployments filters the candidate pool before any positive-tag or regex matching, and untagged deployments are correctly kept (empty intersection is never truthy).
  • _ban_only_base_pool confines ban-only requests (negation tags only, no positive filter) to the default pool, mirroring untagged-request semantics; when no defaults exist it falls back to all deployments, consistent with the existing untagged path.

Confidence Score: 5/5

Safe to merge — the change is self-contained to tag routing, exact-literal matching prevents any new injection surface, and the full positive-tag and regex paths are untouched for callers who do not send negation tags.

All new code paths are exercised by 18 focused tests covering edge cases (bare !, partial-string non-match, ban-only pool confinement, fallback chain exhaustion, tag_regex interaction). Untagged deployments are correctly preserved through the exclusion filter. The ban-only path correctly confines to the default pool and raises when the pool is exhausted. No pre-existing behavior is altered for requests that contain no !-prefixed tags.

No files require special attention.

Important Files Changed

Filename Overview
litellm/router_strategy/tag_based_routing.py Adds _split_tags, _exclude_deployments, _require_candidates, and _ban_only_base_pool helpers; integrates negation-tag (! prefix) logic into get_deployments_for_tag with ban-only semantics and default-pool confinement. Logic is correct and pre-existing behavior is preserved.
tests/test_litellm/router_strategy/test_router_tag_routing.py Adds 18 unit and integration tests covering _split_tags edge cases, single/multiple negation, mixed positive+negation, literal-only semantics, ban-only pool confinement, fallback chain exhaustion, and tag_regex interaction. Existing tests are lightly cleaned up (print removal, unused imports) without weakening assertions.

Reviews (3): Last reviewed commit: "feat(router): tag routing denylist suppo..." | Re-trigger Greptile

Comment thread litellm/router_strategy/tag_based_routing.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

On the summary's "Ban-only negation tags still reject instead of routing to non-denied deployments": this is not reproducible on the head commit. Using the exact scenario described (plain provider tags, no default deployment, no user_agent):

["!provider:anthropic"]                                          -> openai-model
["!provider:anthropic", "!provider:openai"]                      -> vertex-model
["!provider:anthropic", "!provider:openai", "!provider:vertex"]  -> raises no_deployments_with_tag_routing

Single negation selects openai, double selects vertex, and only the deny-all case raises, which is the contract the summary says it expected. These are asserted by test_negation_excludes_matching_deployments and test_negation_multiple_tags_exclude_multiple_providers, both passing in CI.

_ban_only_base_pool returns all healthy deployments when none is tagged default (return defaults if defaults else list(deployments)), so exclusions apply to the full pool and any non-denied deployment survives; _require_candidates raises only when the exclusion set empties the pool. The ValueError the T-Rex run reported for single and double negation is most consistent with a candidate set where every deployment carried the denied tag (for example an all-anthropic fallback group), which is the deny-all case and is supposed to raise


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 8894dbf. Configure here.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

@mateo-berri
mateo-berri marked this pull request as ready for review June 30, 2026 17:59
@mateo-berri
mateo-berri requested a review from tin-berri June 30, 2026 17:59
@mateo-berri
mateo-berri merged commit fecaf5c into litellm_internal_staging Jun 30, 2026
129 checks passed
@mateo-berri
mateo-berri deleted the litellm_tag_routing_negation branch June 30, 2026 18:00
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.

feat(router): tag routing denylist support via ! prefix negation

3 participants