From 1d44cea1efa2ad15ace0d59adb71934276f445cd Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 10 Feb 2026 16:28:36 -0800 Subject: [PATCH 01/18] init schema with TAGS --- litellm-proxy-extras/litellm_proxy_extras/schema.prisma | 1 + litellm/proxy/schema.prisma | 1 + schema.prisma | 1 + 3 files changed, 3 insertions(+) diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index b1ca1f71c9e..558dfcc9517 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -914,6 +914,7 @@ model LiteLLM_PolicyAttachmentTable { teams String[] @default([]) // Team aliases or patterns keys String[] @default([]) // Key aliases or patterns models String[] @default([]) // Model names or patterns + tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"]) created_at DateTime @default(now()) created_by String? updated_at DateTime @default(now()) @updatedAt diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 1750efed92c..37ed0182663 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -911,6 +911,7 @@ model LiteLLM_PolicyAttachmentTable { teams String[] @default([]) // Team aliases or patterns keys String[] @default([]) // Key aliases or patterns models String[] @default([]) // Model names or patterns + tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"]) created_at DateTime @default(now()) created_by String? updated_at DateTime @default(now()) @updatedAt diff --git a/schema.prisma b/schema.prisma index 9a87a491cf7..4329f939a7b 100644 --- a/schema.prisma +++ b/schema.prisma @@ -913,6 +913,7 @@ model LiteLLM_PolicyAttachmentTable { teams String[] @default([]) // Team aliases or patterns keys String[] @default([]) // Key aliases or patterns models String[] @default([]) // Model names or patterns + tags String[] @default([]) // Tag patterns (e.g., ["healthcare", "prod-*"]) created_at DateTime @default(now()) created_by String? updated_at DateTime @default(now()) @updatedAt From 5a3acb43e4f2eb32d91dc6bd0d737e4fe6911d98 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 10 Feb 2026 16:29:10 -0800 Subject: [PATCH 02/18] ui: add policy test --- .../components/policies/policy_test_panel.tsx | 254 ++++++++++++++++++ .../src/components/policies/types.ts | 2 + 2 files changed, 256 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/policies/policy_test_panel.tsx diff --git a/ui/litellm-dashboard/src/components/policies/policy_test_panel.tsx b/ui/litellm-dashboard/src/components/policies/policy_test_panel.tsx new file mode 100644 index 00000000000..055c206ae44 --- /dev/null +++ b/ui/litellm-dashboard/src/components/policies/policy_test_panel.tsx @@ -0,0 +1,254 @@ +import React, { useState, useEffect } from "react"; +import { Form, Select, Alert, Tag, Empty, Typography } from "antd"; +import { Button } from "@tremor/react"; +import { resolvePoliciesCall, teamListCall, keyListCall, modelAvailableCall } from "../networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const { Text } = Typography; + +interface PolicyTestPanelProps { + accessToken: string | null; +} + +interface PolicyMatchDetail { + policy_name: string; + matched_via: string; + guardrails_added: string[]; +} + +interface ResolveResult { + effective_guardrails: string[]; + matched_policies: PolicyMatchDetail[]; +} + +const PolicyTestPanel: React.FC = ({ accessToken }) => { + const [form] = Form.useForm(); + const [isLoading, setIsLoading] = useState(false); + const [result, setResult] = useState(null); + const [hasSearched, setHasSearched] = useState(false); + const [availableTeams, setAvailableTeams] = useState([]); + const [availableKeys, setAvailableKeys] = useState([]); + const [availableModels, setAvailableModels] = useState([]); + const { userId, userRole } = useAuthorized(); + + useEffect(() => { + if (accessToken) { + loadOptions(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accessToken]); + + const loadOptions = async () => { + if (!accessToken) return; + + try { + const teamsResponse = await teamListCall(accessToken, null, userId); + const teamsArray = Array.isArray(teamsResponse) ? teamsResponse : (teamsResponse?.data || []); + setAvailableTeams( + teamsArray.map((t: any) => t.team_alias).filter(Boolean) + ); + } catch (error) { + console.error("Failed to load teams:", error); + } + + try { + const keysResponse = await keyListCall(accessToken, null, null, null, null, null, 1, 100); + const keysArray = keysResponse?.keys || keysResponse?.data || []; + setAvailableKeys( + keysArray.map((k: any) => k.key_alias).filter(Boolean) + ); + } catch (error) { + console.error("Failed to load keys:", error); + } + + try { + const modelsResponse = await modelAvailableCall(accessToken, userId || "", userRole || ""); + const modelsArray = modelsResponse?.data || (Array.isArray(modelsResponse) ? modelsResponse : []); + setAvailableModels( + modelsArray.map((m: any) => m.id || m.model_name).filter(Boolean) + ); + } catch (error) { + console.error("Failed to load models:", error); + } + }; + + const handleTest = async () => { + if (!accessToken) return; + + setIsLoading(true); + setHasSearched(true); + try { + const values = form.getFieldsValue(true); + const context: any = {}; + if (values.team_alias) context.team_alias = values.team_alias; + if (values.key_alias) context.key_alias = values.key_alias; + if (values.model) context.model = values.model; + if (values.tags && values.tags.length > 0) context.tags = values.tags; + + const data = await resolvePoliciesCall(accessToken, context); + setResult(data); + } catch (error) { + console.error("Error resolving policies:", error); + setResult(null); + } finally { + setIsLoading(false); + } + }; + + const handleReset = () => { + form.resetFields(); + setResult(null); + setHasSearched(false); + }; + + return ( +
+
+
+

Policy Simulator

+ + Simulate a request to see which policies and guardrails would apply. Select a team, key, model, or tags below and click "Simulate" to see the results. + +
+ +
+
+ + ({ label: k, value: k }))} + filterOption={(input, option) => + (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) + } + /> + + + + +
+
+ + +
+
+
+ + {!hasSearched && ( +
+
+ + + +
+

No simulation run yet

+

+ Fill in one or more fields above and click "Simulate" to see which policies and guardrails would apply to that request. +

+
+ )} + + {hasSearched && result && ( +
+ {result.matched_policies.length === 0 ? ( + + ) : ( + <> +
+

Effective Guardrails

+
+ {result.effective_guardrails.length > 0 ? ( + result.effective_guardrails.map((g) => ( + {g} + )) + ) : ( + None + )} +
+
+ +
+

Matched Policies

+ + + + + + + + + + {result.matched_policies.map((p) => ( + + + + + + ))} + +
PolicyMatched ViaGuardrails Added
{p.policy_name} + {p.matched_via} + + {p.guardrails_added.length > 0 ? ( +
+ {p.guardrails_added.map((g) => ( + {g} + ))} +
+ ) : ( + None + )} +
+
+ + )} +
+ )} + + {hasSearched && !result && !isLoading && ( + + )} +
+ ); +}; + +export default PolicyTestPanel; diff --git a/ui/litellm-dashboard/src/components/policies/types.ts b/ui/litellm-dashboard/src/components/policies/types.ts index 2430180b9db..3e2c6f1966d 100644 --- a/ui/litellm-dashboard/src/components/policies/types.ts +++ b/ui/litellm-dashboard/src/components/policies/types.ts @@ -23,6 +23,7 @@ export interface PolicyAttachment { teams: string[]; keys: string[]; models: string[]; + tags: string[]; created_at?: string; updated_at?: string; created_by?: string; @@ -53,6 +54,7 @@ export interface PolicyAttachmentCreateRequest { teams?: string[]; keys?: string[]; models?: string[]; + tags?: string[]; } export interface PolicyListResponse { From 9d9f90962bebc9d36dc7f5ec906f253198a2c5c7 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 10 Feb 2026 16:29:55 -0800 Subject: [PATCH 03/18] resolvePoliciesCall --- .../src/components/networking.tsx | 62 +++++++++++++++++++ .../src/components/policies/index.tsx | 10 ++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 023c88c5e83..ecf97cea2dc 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5652,6 +5652,68 @@ export const getResolvedGuardrails = async (accessToken: string, policyId: strin } }; +export const resolvePoliciesCall = async ( + accessToken: string, + context: { team_alias?: string; key_alias?: string; model?: string; tags?: string[] } +) => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/policies/resolve` + : `/policies/resolve`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(context), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return await response.json(); + } catch (error) { + console.error("Failed to resolve policies:", error); + throw error; + } +}; + +export const estimateAttachmentImpactCall = async ( + accessToken: string, + attachmentData: any +) => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/policies/attachments/estimate-impact` + : `/policies/attachments/estimate-impact`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(attachmentData), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return await response.json(); + } catch (error) { + console.error("Failed to estimate attachment impact:", error); + throw error; + } +}; + export const getPromptsList = async (accessToken: string): Promise => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/prompts/list` : `/prompts/list`; diff --git a/ui/litellm-dashboard/src/components/policies/index.tsx b/ui/litellm-dashboard/src/components/policies/index.tsx index 717a6ff612f..204f32e4491 100644 --- a/ui/litellm-dashboard/src/components/policies/index.tsx +++ b/ui/litellm-dashboard/src/components/policies/index.tsx @@ -8,6 +8,7 @@ import PolicyInfoView from "./policy_info"; import AddPolicyForm from "./add_policy_form"; import AttachmentTable from "./attachment_table"; import AddAttachmentForm from "./add_attachment_form"; +import PolicyTestPanel from "./policy_test_panel"; import { getPoliciesList, deletePolicyCall, @@ -177,6 +178,7 @@ const PoliciesPanel: React.FC = ({ Policies Attachments + Policy Simulator @@ -279,7 +281,7 @@ const PoliciesPanel: React.FC = ({ description={

- Policy attachments control where your policies apply. Policies don't do anything until you attach them to specific teams, keys, models, or globally. + Policy attachments control where your policies apply. Policies don't do anything until you attach them to specific teams, keys, models, tags, or globally.

Attachment Scopes:

    @@ -287,6 +289,7 @@ const PoliciesPanel: React.FC = ({
  • Teams - Applies only to specific teams
  • Keys - Applies only to specific API keys (supports wildcards like dev-*)
  • Models - Applies only when specific models are used
  • +
  • Tags - Matches tags from key/team metadata.tags or tags passed dynamically in the request body (metadata.tags). Use this to enforce policies across groups, e.g. "all keys tagged healthcare get HIPAA guardrails." Supports wildcards (prod-*).
= ({ isLoading={isAttachmentsLoading} onDeleteClick={handleDeleteAttachment} isAdmin={isAdmin} + accessToken={accessToken} /> = ({ createAttachment={createPolicyAttachmentCall} /> + + + +
From 3f5279cdada52444d68c5f3107843d79dc309c0e Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 10 Feb 2026 16:31:16 -0800 Subject: [PATCH 04/18] add_policy_sources_to_metadata + headers --- litellm/proxy/common_utils/callback_utils.py | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index faeca9b2aed..e7964a81309 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -394,6 +394,13 @@ def get_logging_caching_headers(request_data: Dict) -> Optional[Dict]: _metadata["applied_policies"] ) + if "policy_sources" in _metadata: + sources = _metadata["policy_sources"] + if isinstance(sources, dict) and sources: + headers["x-litellm-policy-sources"] = ",".join( + f"{name}={reason}" for name, reason in sources.items() + ) + if "semantic-similarity" in _metadata: headers["x-litellm-semantic-similarity"] = str(_metadata["semantic-similarity"]) @@ -441,6 +448,27 @@ def add_policy_to_applied_policies_header( request_data["metadata"] = _metadata +def add_policy_sources_to_metadata( + request_data: Dict, policy_sources: Dict[str, str] +): + """ + Store policy match reasons in metadata for x-litellm-policy-sources header. + + Args: + request_data: The request data dict + policy_sources: Map of policy_name -> matched_via reason + """ + if not policy_sources: + return + _metadata = request_data.get("metadata", None) or {} + existing = _metadata.get("policy_sources", {}) + if not isinstance(existing, dict): + existing = {} + existing.update(policy_sources) + _metadata["policy_sources"] = existing + request_data["metadata"] = _metadata + + def add_guardrail_response_to_standard_logging_object( litellm_logging_obj: Optional["LiteLLMLogging"], guardrail_response: StandardLoggingGuardrailInformation, From 710e132201d05a649bf748f15518a5218750abd3 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 10 Feb 2026 16:32:20 -0800 Subject: [PATCH 05/18] types Policy --- litellm/types/proxy/policy_engine/__init__.py | 9 ++ .../types/proxy/policy_engine/policy_types.py | 33 ++++++-- .../proxy/policy_engine/resolver_types.py | 82 +++++++++++++++++++ 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/litellm/types/proxy/policy_engine/__init__.py b/litellm/types/proxy/policy_engine/__init__.py index bc54c3eb36b..42490c2eddc 100644 --- a/litellm/types/proxy/policy_engine/__init__.py +++ b/litellm/types/proxy/policy_engine/__init__.py @@ -19,6 +19,7 @@ PolicyScope, ) from litellm.types.proxy.policy_engine.resolver_types import ( + AttachmentImpactResponse, PolicyAttachmentCreateRequest, PolicyAttachmentDBResponse, PolicyAttachmentListResponse, @@ -30,6 +31,9 @@ PolicyListDBResponse, PolicyListResponse, PolicyMatchContext, + PolicyMatchDetail, + PolicyResolveRequest, + PolicyResolveResponse, PolicyScopeResponse, PolicySummaryItem, PolicyTestResponse, @@ -75,4 +79,9 @@ "PolicyAttachmentCreateRequest", "PolicyAttachmentDBResponse", "PolicyAttachmentListResponse", + # Resolve types + "PolicyResolveRequest", + "PolicyResolveResponse", + "PolicyMatchDetail", + "AttachmentImpactResponse", ] diff --git a/litellm/types/proxy/policy_engine/policy_types.py b/litellm/types/proxy/policy_engine/policy_types.py index 1c01f89e8b4..f221ba7e038 100644 --- a/litellm/types/proxy/policy_engine/policy_types.py +++ b/litellm/types/proxy/policy_engine/policy_types.py @@ -73,13 +73,15 @@ class PolicyScope(BaseModel): Used internally by PolicyAttachment to define WHERE a policy applies. Scope Fields: - | Field | What it matches | Wildcard support | - |--------|-----------------|----------------------| - | teams | Team aliases | *, healthcare-* | - | keys | Key aliases | *, dev-key-* | - | models | Model names | *, bedrock/*, gpt-* | - - If a field is None or empty, it defaults to matching everything (["*"]). + | Field | What it matches | Wildcard support | Default behavior | + |--------|-----------------|----------------------|---------------------| + | teams | Team aliases | *, healthcare-* | None → matches all | + | keys | Key aliases | *, dev-key-* | None → matches all | + | models | Model names | *, bedrock/*, gpt-* | None → matches all | + | tags | Key/team tags | *, health-*, prod-* | None → not checked | + + If teams/keys/models is None or empty, it defaults to matching everything (["*"]). + If tags is None or empty, the tag dimension is NOT checked (matches all). A request must match ALL specified scope fields for the attachment to apply. """ @@ -95,6 +97,10 @@ class PolicyScope(BaseModel): default=None, description="Model names or wildcard patterns. Use '*' for all models.", ) + tags: Optional[List[str]] = Field( + default=None, + description="Tag patterns to match against key/team tags. Supports wildcards (e.g., health-*).", + ) model_config = ConfigDict(extra="forbid") @@ -110,6 +116,14 @@ def get_models(self) -> List[str]: """Returns models list, defaulting to ['*'] if not specified.""" return self.models if self.models else ["*"] + def get_tags(self) -> List[str]: + """Returns tags list, defaulting to empty list if not specified. + + Unlike teams/keys/models, empty tags means 'do not check tags' + rather than 'match all'. This is because tags are opt-in scoping. + """ + return self.tags if self.tags else [] + # ───────────────────────────────────────────────────────────────────────────── # Policy Guardrails @@ -266,6 +280,10 @@ class PolicyAttachment(BaseModel): default=None, description="Model names or patterns this attachment applies to.", ) + tags: Optional[List[str]] = Field( + default=None, + description="Tag patterns this attachment applies to. Supports wildcards (e.g., health-*).", + ) model_config = ConfigDict(extra="forbid") @@ -281,6 +299,7 @@ def to_policy_scope(self) -> PolicyScope: teams=self.teams, keys=self.keys, models=self.models, + tags=self.tags, ) diff --git a/litellm/types/proxy/policy_engine/resolver_types.py b/litellm/types/proxy/policy_engine/resolver_types.py index 9488b8b0841..88f69325242 100644 --- a/litellm/types/proxy/policy_engine/resolver_types.py +++ b/litellm/types/proxy/policy_engine/resolver_types.py @@ -30,6 +30,10 @@ class PolicyMatchContext(BaseModel): default=None, description="Model name from the request.", ) + tags: Optional[List[str]] = Field( + default=None, + description="Tags from key/team metadata.", + ) model_config = ConfigDict(extra="forbid") @@ -65,6 +69,7 @@ class PolicyScopeResponse(BaseModel): teams: List[str] = Field(default_factory=list) keys: List[str] = Field(default_factory=list) models: List[str] = Field(default_factory=list) + tags: List[str] = Field(default_factory=list) class PolicyGuardrailsResponse(BaseModel): @@ -242,6 +247,10 @@ class PolicyAttachmentCreateRequest(BaseModel): default=None, description="Model names or patterns this attachment applies to.", ) + tags: Optional[List[str]] = Field( + default=None, + description="Tag patterns this attachment applies to. Supports wildcards (e.g., health-*).", + ) class PolicyAttachmentDBResponse(BaseModel): @@ -253,6 +262,7 @@ class PolicyAttachmentDBResponse(BaseModel): teams: List[str] = Field(default_factory=list, description="Team patterns.") keys: List[str] = Field(default_factory=list, description="Key patterns.") models: List[str] = Field(default_factory=list, description="Model patterns.") + tags: List[str] = Field(default_factory=list, description="Tag patterns.") created_at: Optional[datetime] = Field( default=None, description="When the attachment was created." ) @@ -274,3 +284,75 @@ class PolicyAttachmentListResponse(BaseModel): default_factory=list, description="List of policy attachments." ) total_count: int = Field(default=0, description="Total number of attachments.") + + +# ───────────────────────────────────────────────────────────────────────────── +# Policy Resolve Types +# ───────────────────────────────────────────────────────────────────────────── + + +class PolicyResolveRequest(BaseModel): + """Request body for resolving effective policies/guardrails for a context.""" + + team_alias: Optional[str] = Field( + default=None, description="Team alias to resolve for." + ) + key_alias: Optional[str] = Field( + default=None, description="Key alias to resolve for." + ) + model: Optional[str] = Field( + default=None, description="Model name to resolve for." + ) + tags: Optional[List[str]] = Field( + default=None, description="Tags to resolve for." + ) + + +class PolicyMatchDetail(BaseModel): + """Details about why a specific policy matched.""" + + policy_name: str = Field(description="Name of the matched policy.") + matched_via: str = Field( + description="How the policy was matched (e.g., 'tag:healthcare', 'team:health-team', 'scope:*')." + ) + guardrails_added: List[str] = Field( + default_factory=list, + description="Guardrails this policy contributes.", + ) + + +class PolicyResolveResponse(BaseModel): + """Response for resolving effective policies/guardrails for a context.""" + + effective_guardrails: List[str] = Field( + default_factory=list, + description="Final list of guardrails that would be applied.", + ) + matched_policies: List[PolicyMatchDetail] = Field( + default_factory=list, + description="Details about each matched policy and why it matched.", + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Attachment Impact Estimation Types +# ───────────────────────────────────────────────────────────────────────────── + + +class AttachmentImpactResponse(BaseModel): + """Response for estimating the impact of a policy attachment.""" + + affected_keys_count: int = Field( + default=0, description="Number of keys that would be affected." + ) + affected_teams_count: int = Field( + default=0, description="Number of teams that would be affected." + ) + sample_keys: List[str] = Field( + default_factory=list, + description="Sample of affected key aliases (up to 10).", + ) + sample_teams: List[str] = Field( + default_factory=list, + description="Sample of affected team aliases (up to 10).", + ) From 850f71135a54f90e818a6a3ea82b5a113609bebe Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 10 Feb 2026 16:34:23 -0800 Subject: [PATCH 06/18] preview Impact --- .../policies/add_attachment_form.tsx | 120 +++++++++++------- .../components/policies/attachment_table.tsx | 29 +++++ .../policies/build_attachment_data.ts | 25 ++++ .../components/policies/impact_popover.tsx | 84 ++++++++++++ .../policies/impact_preview_alert.tsx | 61 +++++++++ 5 files changed, 275 insertions(+), 44 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/policies/build_attachment_data.ts create mode 100644 ui/litellm-dashboard/src/components/policies/impact_popover.tsx create mode 100644 ui/litellm-dashboard/src/components/policies/impact_preview_alert.tsx diff --git a/ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx b/ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx index 9198eda8a94..7426f4fefa2 100644 --- a/ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx +++ b/ui/litellm-dashboard/src/components/policies/add_attachment_form.tsx @@ -1,10 +1,12 @@ import React, { useState, useEffect } from "react"; import { Modal, Form, Select, Radio, Divider, Typography } from "antd"; import { Button } from "@tremor/react"; -import { Policy, PolicyAttachmentCreateRequest } from "./types"; -import { teamListCall, keyInfoCall, modelAvailableCall } from "../networking"; +import { Policy } from "./types"; +import { teamListCall, keyListCall, modelAvailableCall, estimateAttachmentImpactCall } from "../networking"; import NotificationsManager from "../molecules/notifications_manager"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { buildAttachmentData } from "./build_attachment_data"; +import ImpactPreviewAlert from "./impact_preview_alert"; const { Text } = Typography; @@ -34,6 +36,8 @@ const AddAttachmentForm: React.FC = ({ const [isLoadingTeams, setIsLoadingTeams] = useState(false); const [isLoadingKeys, setIsLoadingKeys] = useState(false); const [isLoadingModels, setIsLoadingModels] = useState(false); + const [isEstimating, setIsEstimating] = useState(false); + const [impactResult, setImpactResult] = useState(null); const { userId, userRole } = useAuthorized(); useEffect(() => { @@ -46,33 +50,30 @@ const AddAttachmentForm: React.FC = ({ const loadTeamsKeysAndModels = async () => { if (!accessToken) return; - // Load teams + // Load teams — teamListCall returns a plain array of team objects setIsLoadingTeams(true); try { - // Pass null for organizationID since we're loading all teams the user has access to const teamsResponse = await teamListCall(accessToken, null, userId); - if (teamsResponse?.data) { - const teamAliases = teamsResponse.data - .map((t: any) => t.team_alias) - .filter(Boolean); - setAvailableTeams(teamAliases); - } + const teamsArray = Array.isArray(teamsResponse) ? teamsResponse : (teamsResponse?.data || []); + const teamAliases = teamsArray + .map((t: any) => t.team_alias) + .filter(Boolean); + setAvailableTeams(teamAliases); } catch (error) { console.error("Failed to load teams:", error); } finally { setIsLoadingTeams(false); } - // Load keys + // Load keys — keyListCall returns {keys: [...], total_count, ...} setIsLoadingKeys(true); try { - const keysResponse = await keyInfoCall(accessToken, []); - if (keysResponse?.data) { - const keyAliases = keysResponse.data - .map((k: any) => k.key_alias) - .filter(Boolean); - setAvailableKeys(keyAliases); - } + const keysResponse = await keyListCall(accessToken, null, null, null, null, null, 1, 100); + const keysArray = keysResponse?.keys || keysResponse?.data || []; + const keyAliases = keysArray + .map((k: any) => k.key_alias) + .filter(Boolean); + setAvailableKeys(keyAliases); } catch (error) { console.error("Failed to load keys:", error); } finally { @@ -83,12 +84,11 @@ const AddAttachmentForm: React.FC = ({ setIsLoadingModels(true); try { const modelsResponse = await modelAvailableCall(accessToken, userId || "", userRole || ""); - if (modelsResponse?.data) { - const modelIds = modelsResponse.data - .map((m: any) => m.id || m.model_name) - .filter(Boolean); - setAvailableModels(modelIds); - } + const modelsArray = modelsResponse?.data || (Array.isArray(modelsResponse) ? modelsResponse : []); + const modelIds = modelsArray + .map((m: any) => m.id || m.model_name) + .filter(Boolean); + setAvailableModels(modelIds); } catch (error) { console.error("Failed to load models:", error); } finally { @@ -99,6 +99,28 @@ const AddAttachmentForm: React.FC = ({ const resetForm = () => { form.resetFields(); setScopeType("global"); + setImpactResult(null); + }; + + const getAttachmentData = () => buildAttachmentData(form.getFieldsValue(true), scopeType); + + const handlePreviewImpact = async () => { + if (!accessToken) return; + try { + await form.validateFields(["policy_name"]); + } catch { + return; + } + setIsEstimating(true); + try { + const data = getAttachmentData(); + const result = await estimateAttachmentImpactCall(accessToken, data); + setImpactResult(result); + } catch (error) { + console.error("Failed to estimate impact:", error); + } finally { + setIsEstimating(false); + } }; const handleClose = () => { @@ -110,30 +132,12 @@ const AddAttachmentForm: React.FC = ({ try { setIsSubmitting(true); await form.validateFields(); - const values = form.getFieldsValue(true); if (!accessToken) { throw new Error("No access token available"); } - const data: PolicyAttachmentCreateRequest = { - policy_name: values.policy_name, - }; - - if (scopeType === "global") { - data.scope = "*"; - } else { - if (values.teams && values.teams.length > 0) { - data.teams = values.teams; - } - if (values.keys && values.keys.length > 0) { - data.keys = values.keys; - } - if (values.models && values.models.length > 0) { - data.models = values.models; - } - } - + const data = getAttachmentData(); await createAttachment(accessToken, data); NotificationsManager.success("Attachment created successfully"); @@ -195,8 +199,8 @@ const AddAttachmentForm: React.FC = ({ value={scopeType} onChange={(e) => setScopeType(e.target.value)} > + Specific (teams, keys, models, or tags) Global (applies to all requests) - Specific (teams, keys, or models) @@ -267,13 +271,41 @@ const AddAttachmentForm: React.FC = ({ style={{ width: "100%" }} /> + + + Matches tags from key/team metadata.tags or tags passed dynamically in the request body. Use * as a suffix wildcard (e.g., prod-* matches prod-us, prod-eu). + + } + > +