feat(policy_engine): add default fallback policy attachments

A policy attachment with default: true applies only when no non-default
attachment matches the request, so an opt-in guardrail policy replaces the
fallback one instead of running alongside it. Supported in config.yaml,
/policies/attachments, the Admin UI Attachments tab and the resolver
(matched_via is prefixed with default:).

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-20 08:24:47 +00:00
parent 6ef7b86748
commit 7fc114c24f
18 changed files with 241 additions and 56 deletions

View file

@ -0,0 +1 @@
ALTER TABLE "LiteLLM_PolicyAttachmentTable" ADD COLUMN IF NOT EXISTS "is_default" BOOLEAN NOT NULL DEFAULT false;

View file

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

View file

@ -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": [
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(<AttachmentTable {...defaultProps} attachments={attachments} />);
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(<AttachmentTable {...defaultProps} isLoading />);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);

View file

@ -181,6 +181,20 @@ export const getAttachmentTableColumns = ({
<span className="font-mono text-xs">{row.original.priority}</span>
),
},
{
id: "default",
accessorFn: (row) => (row.default ? 1 : 0),
meta: { title: "Default" },
header: ({ column }) => <DataTableSortHeader column={column} title="Default" />,
size: 100,
enableSorting: true,
cell: ({ row }) =>
row.original.default ? (
<StatusBadge tone="info" label="Default" tooltip="Applied only when no non-default attachment matches" />
) : (
<span className="text-muted-foreground">-</span>
),
},
{
id: "created_at",
accessorFn: (row) => row.created_at ?? "",

View file

@ -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(<AddAttachmentForm {...defaultProps} createAttachment={createAttachment} />);
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],

View file

@ -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<AddAttachmentFormProps> = ({
/>
)}
</FormField>
<FormField
control={form.control}
name="default"
label={labelWithHint(
"Default (fallback)",
"A default attachment is applied only when no non-default attachment matches the request.",
)}
description="Use this for the guardrail everyone gets unless they opt in to another attachment."
>
{({ value, onChange, ref, ...field }) => (
<Switch {...field} inputRef={ref} checked={value === true} onCheckedChange={onChange} />
)}
</FormField>
</FieldGroup>
{impactResult && <ImpactPreviewAlert impactResult={impactResult} />}

View file

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

View file

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

View file

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

View file

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