diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260920041500_add_policy_attachment_is_default/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260920041500_add_policy_attachment_is_default/migration.sql
new file mode 100644
index 00000000000..a6c45448d03
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260920041500_add_policy_attachment_is_default/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE "LiteLLM_PolicyAttachmentTable" ADD COLUMN IF NOT EXISTS "is_default" BOOLEAN NOT NULL DEFAULT false;
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index d2032cec0d0..2d7e557a9d1 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -1419,6 +1419,7 @@ model LiteLLM_PolicyAttachmentTable {
models String[] @default([]) // Model names or patterns
tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"])
priority Int? // Explicit execution order
+ is_default Boolean @default(false) // Applied only when no non-default attachment matches
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 06e157498aa..6f9a2d8c96d 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -34982,6 +34982,12 @@
"PolicyAttachmentCreateRequest": {
"description": "Request body for creating a policy attachment.",
"properties": {
+ "default": {
+ "default": false,
+ "description": "Apply this attachment only when no non-default attachment matches the request.",
+ "title": "Default",
+ "type": "boolean"
+ },
"keys": {
"anyOf": [
{
@@ -35113,6 +35119,12 @@
"description": "Who created the attachment.",
"title": "Created By"
},
+ "default": {
+ "default": false,
+ "description": "Apply this attachment only when no non-default attachment matches the request.",
+ "title": "Default",
+ "type": "boolean"
+ },
"definition_location": {
"default": "db",
"description": "Where this attachment is defined: 'db' (database) or 'config' (config.yaml).",
@@ -37141,6 +37153,12 @@
"PolicyAttachmentCreateRequest": {
"description": "Request body for creating a policy attachment.",
"properties": {
+ "default": {
+ "default": false,
+ "description": "Apply this attachment only when no non-default attachment matches the request.",
+ "title": "Default",
+ "type": "boolean"
+ },
"keys": {
"anyOf": [
{
diff --git a/litellm/proxy/policy_engine/attachment_registry.py b/litellm/proxy/policy_engine/attachment_registry.py
index 3735c335bd4..04009151487 100644
--- a/litellm/proxy/policy_engine/attachment_registry.py
+++ b/litellm/proxy/policy_engine/attachment_registry.py
@@ -119,6 +119,7 @@ class AttachmentRegistry:
models=attachment_data.get("models"),
tags=attachment_data.get("tags"),
priority=attachment_data.get("priority"),
+ default=attachment_data.get("default", False),
)
def get_attached_policies(self, context: PolicyMatchContext) -> list[str]:
@@ -142,12 +143,14 @@ class AttachmentRegistry:
"""
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher
+ in_scope: Final = tuple(
+ attachment
+ for attachment in self._attachments
+ if PolicyMatcher.scope_matches(scope=attachment.to_policy_scope(), context=context)
+ )
+ non_default: Final = tuple(attachment for attachment in in_scope if not attachment.default)
matching_attachments: Final = sorted(
- (
- attachment
- for attachment in self._attachments
- if PolicyMatcher.scope_matches(scope=attachment.to_policy_scope(), context=context)
- ),
+ non_default or tuple(attachment for attachment in in_scope if attachment.default),
key=_attachment_sort_key,
)
broadest_attachment_by_policy: Final = MappingProxyType(
@@ -169,6 +172,11 @@ class AttachmentRegistry:
@staticmethod
def _describe_match_reason(attachment: PolicyAttachment, context: PolicyMatchContext) -> str:
"""Describe why an attachment matched the context."""
+ reason: Final = AttachmentRegistry._describe_scope_match(attachment, context)
+ return f"default:{reason}" if attachment.default else reason
+
+ @staticmethod
+ def _describe_scope_match(attachment: PolicyAttachment, context: PolicyMatchContext) -> str:
from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher
if attachment.is_global():
@@ -324,6 +332,7 @@ class AttachmentRegistry:
"models": attachment_request.models or [],
"tags": attachment_request.tags or [],
"priority": attachment_request.priority,
+ "is_default": attachment_request.default,
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
"created_by": created_by,
@@ -340,6 +349,7 @@ class AttachmentRegistry:
models=attachment_request.models,
tags=attachment_request.tags,
priority=attachment_request.priority,
+ default=attachment_request.default,
)
self.add_attachment(attachment)
@@ -352,6 +362,7 @@ class AttachmentRegistry:
models=created_attachment.models or [],
tags=created_attachment.tags or [],
priority=created_attachment.priority,
+ default=created_attachment.is_default,
created_at=created_attachment.created_at,
updated_at=created_attachment.updated_at,
created_by=created_attachment.created_by,
@@ -429,6 +440,7 @@ class AttachmentRegistry:
models=attachment.models or [],
tags=attachment.tags or [],
priority=attachment.priority,
+ default=attachment.is_default,
created_at=attachment.created_at,
updated_at=attachment.updated_at,
created_by=attachment.created_by,
@@ -468,6 +480,7 @@ class AttachmentRegistry:
models=a.models or [],
tags=a.tags or [],
priority=a.priority,
+ default=a.is_default,
created_at=a.created_at,
updated_at=a.updated_at,
created_by=a.created_by,
@@ -502,6 +515,7 @@ class AttachmentRegistry:
models=(attachment_response.models if attachment_response.models else None),
tags=attachment_response.tags if attachment_response.tags else None,
priority=attachment_response.priority,
+ default=attachment_response.default,
)
for attachment_response in attachments
]
diff --git a/litellm/proxy/policy_engine/policy_endpoints.py b/litellm/proxy/policy_engine/policy_endpoints.py
index 1e30238c8b4..f4b38bea14e 100644
--- a/litellm/proxy/policy_engine/policy_endpoints.py
+++ b/litellm/proxy/policy_engine/policy_endpoints.py
@@ -61,6 +61,7 @@ def _config_attachment_to_db_response(index: int, attachment: PolicyAttachment)
models=attachment.models or [],
tags=attachment.tags or [],
priority=attachment.priority,
+ default=attachment.default,
definition_location="config",
)
diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma
index d2032cec0d0..2d7e557a9d1 100644
--- a/litellm/proxy/schema.prisma
+++ b/litellm/proxy/schema.prisma
@@ -1419,6 +1419,7 @@ model LiteLLM_PolicyAttachmentTable {
models String[] @default([]) // Model names or patterns
tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"])
priority Int? // Explicit execution order
+ is_default Boolean @default(false) // Applied only when no non-default attachment matches
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
diff --git a/litellm/types/proxy/policy_engine/policy_types.py b/litellm/types/proxy/policy_engine/policy_types.py
index 66e5fbb4b49..73eeffa3585 100644
--- a/litellm/types/proxy/policy_engine/policy_types.py
+++ b/litellm/types/proxy/policy_engine/policy_types.py
@@ -294,6 +294,10 @@ class PolicyAttachment(BaseModel):
le=2147483647,
description="Explicit execution order, lower runs first. Prioritised attachments run before those without one.",
)
+ default: bool = Field(
+ default=False,
+ description="Apply this attachment only when no non-default attachment matches the request.",
+ )
model_config = ConfigDict(extra="forbid")
diff --git a/litellm/types/proxy/policy_engine/resolver_types.py b/litellm/types/proxy/policy_engine/resolver_types.py
index e6f501ed4b5..ebdedb98b12 100644
--- a/litellm/types/proxy/policy_engine/resolver_types.py
+++ b/litellm/types/proxy/policy_engine/resolver_types.py
@@ -311,6 +311,10 @@ class PolicyAttachmentCreateRequest(BaseModel):
le=2147483647,
description="Explicit execution order, lower runs first. Prioritised attachments run before those without one.",
)
+ default: bool = Field(
+ default=False,
+ description="Apply this attachment only when no non-default attachment matches the request.",
+ )
class PolicyAttachmentDBResponse(BaseModel):
@@ -327,6 +331,10 @@ class PolicyAttachmentDBResponse(BaseModel):
default=None,
description="Explicit execution order, lower runs first. Prioritised attachments run before those without one.",
)
+ default: bool = Field(
+ default=False,
+ description="Apply this attachment only when no non-default attachment matches the request.",
+ )
created_at: datetime | None = Field(default=None, description="When the attachment was created.")
updated_at: datetime | None = Field(default=None, description="When the attachment was last updated.")
created_by: str | None = Field(default=None, description="Who created the attachment.")
diff --git a/schema.prisma b/schema.prisma
index d2032cec0d0..2d7e557a9d1 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -1419,6 +1419,7 @@ model LiteLLM_PolicyAttachmentTable {
models String[] @default([]) // Model names or patterns
tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"])
priority Int? // Explicit execution order
+ is_default Boolean @default(false) // Applied only when no non-default attachment matches
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
diff --git a/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py b/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py
index 089bec59583..b419f3db060 100644
--- a/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py
+++ b/tests/test_litellm/proxy/policy_engine/test_attachment_registry.py
@@ -30,9 +30,7 @@ class TestGetAttachedPolicies:
)
# Should match any context
- context = PolicyMatchContext(
- team_alias="any-team", key_alias="any-key", model="any-model"
- )
+ context = PolicyMatchContext(team_alias="any-team", key_alias="any-key", model="any-model")
attached = registry.get_attached_policies(context)
assert "global-baseline" in attached
@@ -46,15 +44,11 @@ class TestGetAttachedPolicies:
)
# Match
- context = PolicyMatchContext(
- team_alias="healthcare-team", key_alias="key", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="healthcare-team", key_alias="key", model="gpt-4")
assert "healthcare-policy" in registry.get_attached_policies(context)
# No match - different team
- context_other = PolicyMatchContext(
- team_alias="finance-team", key_alias="key", model="gpt-4"
- )
+ context_other = PolicyMatchContext(team_alias="finance-team", key_alias="key", model="gpt-4")
assert "healthcare-policy" not in registry.get_attached_policies(context_other)
def test_key_wildcard_pattern_attachment(self):
@@ -67,15 +61,11 @@ class TestGetAttachedPolicies:
)
# Match - key starts with dev-key-
- context = PolicyMatchContext(
- team_alias="team", key_alias="dev-key-123", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="team", key_alias="dev-key-123", model="gpt-4")
assert "dev-policy" in registry.get_attached_policies(context)
# No match - different prefix
- context_prod = PolicyMatchContext(
- team_alias="team", key_alias="prod-key-123", model="gpt-4"
- )
+ context_prod = PolicyMatchContext(team_alias="team", key_alias="prod-key-123", model="gpt-4")
assert "dev-policy" not in registry.get_attached_policies(context_prod)
def test_model_specific_attachment(self):
@@ -92,9 +82,7 @@ class TestGetAttachedPolicies:
assert "gpt4-policy" in registry.get_attached_policies(context)
# No match
- context_other = PolicyMatchContext(
- team_alias="team", key_alias="key", model="gpt-3.5"
- )
+ context_other = PolicyMatchContext(team_alias="team", key_alias="key", model="gpt-3.5")
assert "gpt4-policy" not in registry.get_attached_policies(context_other)
def test_model_wildcard_pattern(self):
@@ -107,15 +95,11 @@ class TestGetAttachedPolicies:
)
# Match
- context = PolicyMatchContext(
- team_alias="team", key_alias="key", model="bedrock/claude-3"
- )
+ context = PolicyMatchContext(team_alias="team", key_alias="key", model="bedrock/claude-3")
assert "bedrock-policy" in registry.get_attached_policies(context)
# No match
- context_other = PolicyMatchContext(
- team_alias="team", key_alias="key", model="openai/gpt-4"
- )
+ context_other = PolicyMatchContext(team_alias="team", key_alias="key", model="openai/gpt-4")
assert "bedrock-policy" not in registry.get_attached_policies(context_other)
def test_multiple_attachments_match_same_context(self):
@@ -129,9 +113,7 @@ class TestGetAttachedPolicies:
]
)
- context = PolicyMatchContext(
- team_alias="healthcare-team", key_alias="key", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="healthcare-team", key_alias="key", model="gpt-4")
attached = registry.get_attached_policies(context)
# All three should match
@@ -277,9 +259,7 @@ class TestGetAttachedPolicies:
]
)
- context = PolicyMatchContext(
- team_alias="healthcare-team", key_alias="key", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="healthcare-team", key_alias="key", model="gpt-4")
attached = registry.get_attached_policies(context)
# Should only appear once
@@ -288,9 +268,7 @@ class TestGetAttachedPolicies:
def test_many_distinct_policies_resolve_in_linear_time(self):
policy_count = 20_000
registry = AttachmentRegistry()
- registry.load_attachments(
- [{"policy": f"policy-{index}", "scope": "*"} for index in range(policy_count)]
- )
+ registry.load_attachments([{"policy": f"policy-{index}", "scope": "*"} for index in range(policy_count)])
context = PolicyMatchContext(team_alias="team", key_alias="key", model="gpt-4")
started = time.perf_counter()
@@ -318,9 +296,7 @@ class TestGetAttachedPolicies:
]
)
- context = PolicyMatchContext(
- team_alias="finance-team", key_alias="key", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="finance-team", key_alias="key", model="gpt-4")
attached = registry.get_attached_policies(context)
assert attached == []
@@ -338,23 +314,15 @@ class TestGetAttachedPolicies:
)
# Match - both team and model match
- context = PolicyMatchContext(
- team_alias="healthcare-team", key_alias="key", model="gpt-4"
- )
+ context = PolicyMatchContext(team_alias="healthcare-team", key_alias="key", model="gpt-4")
assert "strict-policy" in registry.get_attached_policies(context)
# No match - team matches but model doesn't
- context_wrong_model = PolicyMatchContext(
- team_alias="healthcare-team", key_alias="key", model="gpt-3.5"
- )
- assert "strict-policy" not in registry.get_attached_policies(
- context_wrong_model
- )
+ context_wrong_model = PolicyMatchContext(team_alias="healthcare-team", key_alias="key", model="gpt-3.5")
+ assert "strict-policy" not in registry.get_attached_policies(context_wrong_model)
# No match - model matches but team doesn't
- context_wrong_team = PolicyMatchContext(
- team_alias="finance-team", key_alias="key", model="gpt-4"
- )
+ context_wrong_team = PolicyMatchContext(team_alias="finance-team", key_alias="key", model="gpt-4")
assert "strict-policy" not in registry.get_attached_policies(context_wrong_team)
@@ -527,6 +495,79 @@ class TestMatchAttribution:
assert "catch-all" in attached
+class TestDefaultAttachments:
+ """`default: true` attachments apply only when no non-default attachment matches."""
+
+ @staticmethod
+ def _registry() -> AttachmentRegistry:
+ registry = AttachmentRegistry()
+ registry.load_attachments(
+ [
+ {"policy": "guardrail-y", "scope": "*", "default": True},
+ {"policy": "guardrail-x", "tags": ["opt-in"]},
+ ]
+ )
+ return registry
+
+ def test_opted_in_request_gets_only_the_opt_in_policy(self):
+ context = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-5.2", tags=["opt-in"])
+
+ assert self._registry().get_attached_policies(context) == ["guardrail-x"]
+
+ def test_request_without_opt_in_falls_back_to_default_policy(self):
+ context = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-5.2")
+
+ assert self._registry().get_attached_policies(context) == ["guardrail-y"]
+
+ def test_default_attachment_still_honors_its_own_scope(self):
+ registry = AttachmentRegistry()
+ registry.load_attachments([{"policy": "team-default", "teams": ["team-a"], "default": True}])
+
+ assert registry.get_attached_policies(PolicyMatchContext(team_alias="team-a", key_alias="k", model="m")) == [
+ "team-default"
+ ]
+ assert registry.get_attached_policies(PolicyMatchContext(team_alias="team-b", key_alias="k", model="m")) == []
+
+ def test_all_matching_defaults_apply_when_nothing_else_matches(self):
+ registry = AttachmentRegistry()
+ registry.load_attachments(
+ [
+ {"policy": "default-a", "scope": "*", "default": True},
+ {"policy": "default-b", "teams": ["team-a"], "default": True},
+ {"policy": "opt-in", "tags": ["opt-in"]},
+ ]
+ )
+ context = PolicyMatchContext(team_alias="team-a", key_alias="k", model="m")
+
+ assert registry.get_attached_policies(context) == ["default-a", "default-b"]
+
+ def test_non_default_attachments_remain_additive(self):
+ registry = AttachmentRegistry()
+ registry.load_attachments(
+ [
+ {"policy": "baseline", "scope": "*"},
+ {"policy": "opt-in", "tags": ["opt-in"]},
+ {"policy": "fallback", "scope": "*", "default": True},
+ ]
+ )
+ context = PolicyMatchContext(team_alias="t", key_alias="k", model="m", tags=["opt-in"])
+
+ assert registry.get_attached_policies(context) == ["baseline", "opt-in"]
+
+ def test_default_match_reason_is_labelled(self):
+ context = PolicyMatchContext(team_alias="t", key_alias="k", model="m")
+
+ results = self._registry().get_attached_policies_with_reasons(context)
+
+ assert results == [{"policy_name": "guardrail-y", "matched_via": "default:scope:*"}]
+
+ def test_default_defaults_to_false_when_omitted(self):
+ registry = AttachmentRegistry()
+ registry.load_attachments([{"policy": "p"}])
+
+ assert registry.get_all_attachments()[0].default is False
+
+
class TestAttachmentRegistrySingleton:
"""Test global singleton behavior."""
@@ -557,6 +598,7 @@ def _make_db_attachment_row(
scope: str | None = None,
teams: list[str] | None = None,
priority: int | None = None,
+ is_default: bool = False,
) -> MagicMock:
row = MagicMock()
row.attachment_id = attachment_id
@@ -567,6 +609,7 @@ def _make_db_attachment_row(
row.models = []
row.tags = []
row.priority = priority
+ row.is_default = is_default
row.created_at = datetime.now(timezone.utc)
row.updated_at = datetime.now(timezone.utc)
row.created_by = None
@@ -576,9 +619,7 @@ def _make_db_attachment_row(
def _prisma_with_attachment_rows(rows: list[MagicMock]) -> MagicMock:
prisma = MagicMock()
- prisma.configure_mock(
- **{"db.litellm_policyattachmenttable.find_many": AsyncMock(return_value=rows)}
- )
+ prisma.configure_mock(**{"db.litellm_policyattachmenttable.find_many": AsyncMock(return_value=rows)})
return prisma
@@ -629,6 +670,15 @@ class TestConfigAttachmentsPreservedAcrossDbSync:
assert registry.get_all_attachments()[0].priority == 7
+ @pytest.mark.asyncio
+ async def test_sync_round_trips_db_attachment_default_flag(self):
+ registry = AttachmentRegistry()
+ db_row = _make_db_attachment_row(is_default=True)
+
+ await registry.sync_attachments_from_db(_prisma_with_attachment_rows([db_row]))
+
+ assert registry.get_all_attachments()[0].default is True
+
@pytest.mark.asyncio
async def test_clear_removes_config_snapshot_so_sync_does_not_resurrect(self):
registry = AttachmentRegistry()
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx
index 43ad6a7cc9e..be83f73bb2e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx
@@ -65,6 +65,19 @@ describe("AttachmentTable", () => {
);
});
+ it("should show a Default badge only for default attachments", () => {
+ const attachments = [
+ makeAttachment({ attachment_id: "att-def00001", policy_name: "fallback", default: true }),
+ makeAttachment({ attachment_id: "att-def00002", policy_name: "regular" }),
+ ];
+ renderWithProviders();
+ const rows = screen.getAllByRole("row").slice(1);
+ const fallbackRow = rows.find((row) => within(row).queryByText("fallback"));
+ const regularRow = rows.find((row) => within(row).queryByText("regular"));
+ expect(within(fallbackRow!).getByText("Default")).toBeInTheDocument();
+ expect(within(regularRow!).queryByText("Default")).not.toBeInTheDocument();
+ });
+
it("should show skeleton rows when isLoading is true", () => {
renderWithProviders();
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTableColumns.tsx
index 9a190401d08..3265b9db834 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTableColumns.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTableColumns.tsx
@@ -181,6 +181,20 @@ export const getAttachmentTableColumns = ({
{row.original.priority}
),
},
+ {
+ id: "default",
+ accessorFn: (row) => (row.default ? 1 : 0),
+ meta: { title: "Default" },
+ header: ({ column }) => ,
+ size: 100,
+ enableSorting: true,
+ cell: ({ row }) =>
+ row.original.default ? (
+
+ ) : (
+ -
+ ),
+ },
{
id: "created_at",
accessorFn: (row) => row.created_at ?? "",
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx
index dfc023d428e..14af4a2b8f3 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.test.tsx
@@ -237,6 +237,21 @@ describe("AddAttachmentForm", () => {
expect(createAttachment).toHaveBeenCalledWith("test-token", { policy_name: "policy-alpha", scope: "*" });
});
+ it("sends default: true when the Default switch is turned on", async () => {
+ const user = userEvent.setup();
+ const createAttachment = vi.fn().mockResolvedValue({});
+ renderWithProviders();
+ await selectPolicy(user, "policy-alpha");
+ await user.click(screen.getByRole("switch", { name: /default/i }));
+ await submit(user);
+ await waitFor(() => expect(createAttachment).toHaveBeenCalledTimes(1));
+ expect(createAttachment).toHaveBeenCalledWith("test-token", {
+ policy_name: "policy-alpha",
+ scope: "*",
+ default: true,
+ });
+ });
+
it.each([
["2147483648", /at most 2147483647/i],
["-2147483649", /at least -2147483648/i],
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx
index 02463a89139..74c4978392f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_attachment_form.tsx
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
@@ -38,6 +39,7 @@ interface AttachmentFormValues {
models: string[];
tags: string[];
priority: number | null;
+ default: boolean;
}
const EMPTY_VALUES: AttachmentFormValues = {
@@ -47,6 +49,7 @@ const EMPTY_VALUES: AttachmentFormValues = {
models: [],
tags: [],
priority: null,
+ default: false,
};
const INT32_MIN = -2147483648;
@@ -64,6 +67,7 @@ const attachmentShape = {
.min(INT32_MIN, `Priority must be at least ${INT32_MIN}`)
.max(INT32_MAX, `Priority must be at most ${INT32_MAX}`)
.nullable(),
+ default: z.boolean(),
};
const buildAttachmentSchema = (scopeType: ScopeType, teamsLoaded: boolean, availableTeams: string[]) =>
@@ -453,6 +457,20 @@ const AddAttachmentForm: React.FC = ({
/>
)}
+
+
+ {({ value, onChange, ref, ...field }) => (
+
+ )}
+
{impactResult && }
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/build_attachment_data.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/build_attachment_data.test.ts
index 930e755f242..80617a40f13 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/build_attachment_data.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/build_attachment_data.test.ts
@@ -80,6 +80,16 @@ describe("buildAttachmentData", () => {
});
});
+ describe("default", () => {
+ it.each(["global", "specific"] as const)("should send default: true for a %s scope", (scopeType) => {
+ expect(buildAttachmentData({ policy_name: "p", default: true }, scopeType).default).toBe(true);
+ });
+
+ it.each([undefined, false])("should omit default when it is %s", (value) => {
+ expect(buildAttachmentData({ policy_name: "p", default: value }, "specific")).not.toHaveProperty("default");
+ });
+ });
+
describe("priority", () => {
it.each(["global", "specific"] as const)("should include priority for a %s scope", (scopeType) => {
expect(buildAttachmentData({ policy_name: "p", priority: 0 }, scopeType).priority).toBe(0);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/build_attachment_data.ts b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/build_attachment_data.ts
index 8b21142df74..8b50cd7bdc1 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/build_attachment_data.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/build_attachment_data.ts
@@ -7,6 +7,7 @@ export interface AttachmentFormInput {
models?: string[];
tags?: string[];
priority?: number | null;
+ default?: boolean;
}
export function buildAttachmentData(
@@ -25,5 +26,6 @@ export function buildAttachmentData(
if (formValues.tags && formValues.tags.length > 0) data.tags = formValues.tags;
}
if (typeof formValues.priority === "number") data.priority = formValues.priority;
+ if (formValues.default === true) data.default = true;
return data;
}
diff --git a/ui/litellm-dashboard/src/components/policies/types.ts b/ui/litellm-dashboard/src/components/policies/types.ts
index 9f3ef02ba5d..430864f93df 100644
--- a/ui/litellm-dashboard/src/components/policies/types.ts
+++ b/ui/litellm-dashboard/src/components/policies/types.ts
@@ -45,6 +45,7 @@ export interface PolicyAttachment {
models: string[];
tags: string[];
priority?: number | null;
+ default?: boolean;
created_at?: string;
updated_at?: string;
created_by?: string;
@@ -80,6 +81,7 @@ export interface PolicyAttachmentCreateRequest {
models?: string[];
tags?: string[];
priority?: number;
+ default?: boolean;
}
export interface PolicyListResponse {
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index e33764c3d1a..ccb5fda2149 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -35164,6 +35164,12 @@ export interface components {
* @description Request body for creating a policy attachment.
*/
PolicyAttachmentCreateRequest: {
+ /**
+ * Default
+ * @description Apply this attachment only when no non-default attachment matches the request.
+ * @default false
+ */
+ default: boolean;
/**
* Keys
* @description Key aliases or patterns this attachment applies to.
@@ -35220,6 +35226,12 @@ export interface components {
* @description Who created the attachment.
*/
created_by?: string | null;
+ /**
+ * Default
+ * @description Apply this attachment only when no non-default attachment matches the request.
+ * @default false
+ */
+ default: boolean;
/**
* Definition Location
* @description Where this attachment is defined: 'db' (database) or 'config' (config.yaml).