mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
6ef7b86748
commit
7fc114c24f
18 changed files with 241 additions and 56 deletions
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "LiteLLM_PolicyAttachmentTable" ADD COLUMN IF NOT EXISTS "is_default" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
12
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue