From e0e1ac617acedab7c1fb35a6372210a9e933efde Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 12:49:07 -0700 Subject: [PATCH 01/18] [Test] UI: Add unit tests for 10 previously untested components Add Vitest + RTL tests for HelpLink, DebugWarningBanner, ExportFormatSelector, ExportTypeSelector, ExportSummary, MetricCard, PolicySelect, ComplexityRouterConfig, RateLimitTypeFormItem, and AgentCardGrid (67 tests total). Co-Authored-By: Claude Opus 4.6 --- .../components/DebugWarningBanner.test.tsx | 41 +++++++++ .../ExportFormatSelector.test.tsx | 20 +++++ .../EntityUsageExport/ExportSummary.test.tsx | 59 ++++++++++++ .../ExportTypeSelector.test.tsx | 46 ++++++++++ .../GuardrailsMonitor/MetricCard.test.tsx | 49 ++++++++++ .../src/components/HelpLink.test.tsx | 24 +++++ .../ToolPolicies/PolicySelect.test.tsx | 84 +++++++++++++++++ .../add_model/ComplexityRouterConfig.test.tsx | 82 +++++++++++++++++ .../agents/agent_card_grid.test.tsx | 90 +++++++++++++++++++ .../RateLimitTypeFormItem.test.tsx | 61 +++++++++++++ 10 files changed, 556 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/DebugWarningBanner.test.tsx create mode 100644 ui/litellm-dashboard/src/components/EntityUsageExport/ExportFormatSelector.test.tsx create mode 100644 ui/litellm-dashboard/src/components/EntityUsageExport/ExportSummary.test.tsx create mode 100644 ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx create mode 100644 ui/litellm-dashboard/src/components/agents/agent_card_grid.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/RateLimitTypeFormItem.test.tsx diff --git a/ui/litellm-dashboard/src/components/DebugWarningBanner.test.tsx b/ui/litellm-dashboard/src/components/DebugWarningBanner.test.tsx new file mode 100644 index 0000000000..2412a84da3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/DebugWarningBanner.test.tsx @@ -0,0 +1,41 @@ +import { renderWithProviders, screen } from "../../tests/test-utils"; +import { vi } from "vitest"; +import { DebugWarningBanner } from "./DebugWarningBanner"; + +vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness", () => ({ + useHealthReadiness: vi.fn(), +})); + +import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness"; + +describe("DebugWarningBanner", () => { + it("should render", () => { + vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any); + renderWithProviders(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + it("should show warning when detailed debug mode is active", () => { + vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any); + renderWithProviders(); + expect(screen.getByText(/Performance Warning: Detailed Debug Mode Active/i)).toBeInTheDocument(); + }); + + it("should mention LITELLM_LOG=DEBUG in the description", () => { + vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any); + renderWithProviders(); + expect(screen.getByText("LITELLM_LOG=DEBUG")).toBeInTheDocument(); + }); + + it("should render nothing when is_detailed_debug is false", () => { + vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: false } } as any); + const { container } = renderWithProviders(); + expect(container).toBeEmptyDOMElement(); + }); + + it("should render nothing when health data is undefined", () => { + vi.mocked(useHealthReadiness).mockReturnValue({ data: undefined } as any); + const { container } = renderWithProviders(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportFormatSelector.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportFormatSelector.test.tsx new file mode 100644 index 0000000000..8447df4a6b --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportFormatSelector.test.tsx @@ -0,0 +1,20 @@ +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import { vi } from "vitest"; +import ExportFormatSelector from "./ExportFormatSelector"; + +describe("ExportFormatSelector", () => { + it("should render", () => { + renderWithProviders(); + expect(screen.getByText("Format")).toBeInTheDocument(); + }); + + it("should display the current value", () => { + renderWithProviders(); + expect(screen.getByText("CSV (Excel, Google Sheets)")).toBeInTheDocument(); + }); + + it("should display JSON label when json is selected", () => { + renderWithProviders(); + expect(screen.getByText("JSON (includes metadata)")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportSummary.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportSummary.test.tsx new file mode 100644 index 0000000000..8ebeb33b1e --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportSummary.test.tsx @@ -0,0 +1,59 @@ +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import ExportSummary from "./ExportSummary"; + +describe("ExportSummary", () => { + it("should render", () => { + const dateRange = { + from: new Date("2024-01-01"), + to: new Date("2024-01-31"), + }; + const { container } = renderWithProviders( + + ); + expect(container).not.toBeEmptyDOMElement(); + }); + + it("should display the date range", () => { + const from = new Date(2024, 0, 1); + const to = new Date(2024, 0, 31); + const dateRange = { from, to }; + renderWithProviders( + + ); + expect(screen.getByText(new RegExp(from.toLocaleDateString()))).toBeInTheDocument(); + expect(screen.getByText(new RegExp(to.toLocaleDateString()))).toBeInTheDocument(); + }); + + it("should show filter count when filters are selected", () => { + const dateRange = { + from: new Date("2024-01-01"), + to: new Date("2024-01-31"), + }; + renderWithProviders( + + ); + expect(screen.getByText(/3 filters/)).toBeInTheDocument(); + }); + + it("should show singular 'filter' for one filter", () => { + const dateRange = { + from: new Date("2024-01-01"), + to: new Date("2024-01-31"), + }; + renderWithProviders( + + ); + expect(screen.getByText(/1 filter$/)).toBeInTheDocument(); + }); + + it("should not show filter count when no filters selected", () => { + const dateRange = { + from: new Date("2024-01-01"), + to: new Date("2024-01-31"), + }; + renderWithProviders( + + ); + expect(screen.queryByText(/filter/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx new file mode 100644 index 0000000000..36d2e12b71 --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx @@ -0,0 +1,46 @@ +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import ExportTypeSelector from "./ExportTypeSelector"; + +describe("ExportTypeSelector", () => { + it("should render", () => { + renderWithProviders( + + ); + expect(screen.getByText("Export type")).toBeInTheDocument(); + }); + + it("should display entity type in radio labels", () => { + renderWithProviders( + + ); + expect(screen.getByText(/Day-by-day breakdown by team$/)).toBeInTheDocument(); + expect(screen.getByText(/Day-by-day breakdown by team and key/)).toBeInTheDocument(); + expect(screen.getByText(/Day-by-day by team and model/)).toBeInTheDocument(); + }); + + it("should display the correct entity type for different entities", () => { + renderWithProviders( + + ); + expect(screen.getByText(/Day-by-day breakdown by organization$/)).toBeInTheDocument(); + }); + + it("should call onChange when a radio option is selected", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithProviders( + + ); + await user.click(screen.getByRole("radio", { name: /Day-by-day breakdown by team and key/i })); + expect(onChange).toHaveBeenCalledWith("daily_with_keys"); + }); + + it("should have the correct radio checked", () => { + renderWithProviders( + + ); + expect(screen.getByRole("radio", { name: /Day-by-day by team and model/i })).toBeChecked(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.test.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.test.tsx new file mode 100644 index 0000000000..d30df1a006 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.test.tsx @@ -0,0 +1,49 @@ +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import React from "react"; +import { MetricCard } from "./MetricCard"; + +describe("MetricCard", () => { + it("should render", () => { + renderWithProviders(); + expect(screen.getByText("Total Requests")).toBeInTheDocument(); + }); + + it("should display the label and value", () => { + renderWithProviders(); + expect(screen.getByText("Success Rate")).toBeInTheDocument(); + expect(screen.getByText("98.5%")).toBeInTheDocument(); + }); + + it("should display numeric values", () => { + renderWithProviders(); + expect(screen.getByText("42")).toBeInTheDocument(); + }); + + it("should render icon when provided", () => { + renderWithProviders( + icon} + /> + ); + expect(screen.getByTestId("test-icon")).toBeInTheDocument(); + }); + + it("should not render icon container when no icon provided", () => { + renderWithProviders(); + expect(screen.queryByTestId("test-icon")).not.toBeInTheDocument(); + }); + + it("should render subtitle when provided", () => { + renderWithProviders( + + ); + expect(screen.getByText("Last 24 hours")).toBeInTheDocument(); + }); + + it("should not render subtitle when not provided", () => { + renderWithProviders(); + expect(screen.queryByText("Last 24 hours")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/HelpLink.test.tsx b/ui/litellm-dashboard/src/components/HelpLink.test.tsx index 72033bd93a..76c42b8159 100644 --- a/ui/litellm-dashboard/src/components/HelpLink.test.tsx +++ b/ui/litellm-dashboard/src/components/HelpLink.test.tsx @@ -23,6 +23,11 @@ describe("HelpLink", () => { expect(screen.getByText("Custom docs link")).toBeInTheDocument(); }); + it("should have the correct href", () => { + renderWithProviders(); + expect(screen.getByRole("link")).toHaveAttribute("href", "https://docs.example.com/test"); + }); + it("should include a screen-reader-only label for accessibility", () => { renderWithProviders(); @@ -46,7 +51,21 @@ describe("HelpIcon", () => { expect(screen.getByText("Tooltip help text")).toBeInTheDocument(); }); + it("should hide tooltip content when not hovered", () => { + renderWithProviders(); + expect(screen.queryByText("Hidden tooltip")).not.toBeInTheDocument(); + }); + it("should show learn more link when learnMoreHref is provided", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + await user.hover(screen.getByRole("button", { name: /help information/i })); + expect(screen.getByText("Learn more")).toBeInTheDocument(); + }); + + it("should use custom learn more text when provided", async () => { const user = userEvent.setup(); renderWithProviders( { expect(screen.getByRole("button", { name: /docs/i })).toBeInTheDocument(); }); + it("should hide menu items initially", () => { + renderWithProviders(); + expect(screen.queryByText("Custom pricing")).not.toBeInTheDocument(); + }); + it("should show menu items when button is clicked", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx new file mode 100644 index 0000000000..cde59fe9c6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx @@ -0,0 +1,84 @@ +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import { vi } from "vitest"; +import { + PolicySelect, + policyStyle, + INPUT_POLICY_OPTIONS, + OUTPUT_POLICY_OPTIONS, +} from "./PolicySelect"; + +describe("policyStyle", () => { + it("should return the matching option for a known policy", () => { + expect(policyStyle("trusted")).toEqual(INPUT_POLICY_OPTIONS[1]); + }); + + it("should return the matching option for blocked", () => { + expect(policyStyle("blocked")).toEqual(INPUT_POLICY_OPTIONS[2]); + }); + + it("should return the first option as fallback for unknown policy", () => { + expect(policyStyle("unknown")).toEqual(INPUT_POLICY_OPTIONS[0]); + }); +}); + +describe("PolicySelect", () => { + it("should render", () => { + renderWithProviders( + + ); + expect(screen.getByText("untrusted")).toBeInTheDocument(); + }); + + it("should show the current policy value", () => { + renderWithProviders( + + ); + expect(screen.getByText("trusted")).toBeInTheDocument(); + }); + + it("should be disabled when saving is true", () => { + renderWithProviders( + + ); + expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"); + expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled"); + }); + + it("should not be disabled when saving is false", () => { + renderWithProviders( + + ); + expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled"); + }); +}); + +describe("Policy option constants", () => { + it("should have 3 input policy options", () => { + expect(INPUT_POLICY_OPTIONS).toHaveLength(3); + }); + + it("should have 2 output policy options (no blocked)", () => { + expect(OUTPUT_POLICY_OPTIONS).toHaveLength(2); + expect(OUTPUT_POLICY_OPTIONS.map((o) => o.value)).toEqual(["untrusted", "trusted"]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx new file mode 100644 index 0000000000..59d8721b14 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -0,0 +1,82 @@ +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import { vi } from "vitest"; +import ComplexityRouterConfig from "./ComplexityRouterConfig"; + +const mockModelInfo = [ + { model_group: "gpt-4" }, + { model_group: "gpt-3.5-turbo" }, + { model_group: "claude-3-opus" }, +] as any[]; + +const defaultTiers = { + SIMPLE: "gpt-3.5-turbo", + MEDIUM: "gpt-3.5-turbo", + COMPLEX: "gpt-4", + REASONING: "claude-3-opus", +}; + +describe("ComplexityRouterConfig", () => { + it("should render", () => { + renderWithProviders( + + ); + expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument(); + }); + + it("should display all four tier labels", () => { + renderWithProviders( + + ); + expect(screen.getByText("Simple Tier")).toBeInTheDocument(); + expect(screen.getByText("Medium Tier")).toBeInTheDocument(); + expect(screen.getByText("Complex Tier")).toBeInTheDocument(); + expect(screen.getByText("Reasoning Tier")).toBeInTheDocument(); + }); + + it("should show example queries for each tier", () => { + renderWithProviders( + + ); + expect(screen.getByText(/Hello!/)).toBeInTheDocument(); + expect(screen.getByText(/Explain how REST APIs work/)).toBeInTheDocument(); + expect(screen.getByText(/Design a microservices architecture/)).toBeInTheDocument(); + expect(screen.getByText(/Think step by step/)).toBeInTheDocument(); + }); + + it("should display the how classification works section", () => { + renderWithProviders( + + ); + expect(screen.getByText("How Classification Works")).toBeInTheDocument(); + }); + + it("should show score thresholds in the classification section", () => { + renderWithProviders( + + ); + expect(screen.getByText(/Score < 0.15/)).toBeInTheDocument(); + expect(screen.getByText(/Score 0.15 - 0.35/)).toBeInTheDocument(); + expect(screen.getByText(/Score 0.35 - 0.60/)).toBeInTheDocument(); + expect(screen.getByText(/Score > 0.60/)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/agents/agent_card_grid.test.tsx b/ui/litellm-dashboard/src/components/agents/agent_card_grid.test.tsx new file mode 100644 index 0000000000..c8a21d1b6e --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_card_grid.test.tsx @@ -0,0 +1,90 @@ +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import { vi } from "vitest"; +import AgentCardGrid from "./agent_card_grid"; +import type { Agent, AgentKeyInfo } from "./types"; + +vi.mock("./agent_card", () => ({ + default: ({ agent, onAgentClick }: any) => ( +
onAgentClick(agent.agent_id)}> + {agent.agent_name} +
+ ), +})); + +const mockAgents: Agent[] = [ + { + agent_id: "agent-1", + agent_name: "Test Agent 1", + litellm_params: { model: "gpt-4" }, + agent_card_params: { description: "First agent" }, + }, + { + agent_id: "agent-2", + agent_name: "Test Agent 2", + litellm_params: { model: "claude-3" }, + agent_card_params: { description: "Second agent" }, + }, +]; + +const mockKeyInfoMap: Record = { + "agent-1": { has_key: true, key_alias: "key-1" }, + "agent-2": { has_key: false }, +}; + +const defaultProps = { + agentsList: mockAgents, + keyInfoMap: mockKeyInfoMap, + isLoading: false, + onDeleteClick: vi.fn(), + accessToken: "test-token", + onAgentUpdated: vi.fn(), + isAdmin: true, + onAgentClick: vi.fn(), +}; + +describe("AgentCardGrid", () => { + it("should render", () => { + renderWithProviders(); + expect(screen.getByText("Test Agent 1")).toBeInTheDocument(); + }); + + it("should render all agent cards", () => { + renderWithProviders(); + expect(screen.getByText("Test Agent 1")).toBeInTheDocument(); + expect(screen.getByText("Test Agent 2")).toBeInTheDocument(); + }); + + it("should show loading skeletons when isLoading is true", () => { + renderWithProviders(); + expect(screen.queryByText("Test Agent 1")).not.toBeInTheDocument(); + }); + + it("should show admin empty state message when no agents and isAdmin", () => { + renderWithProviders( + + ); + expect( + screen.getByText("No agents found. Create one to get started.") + ).toBeInTheDocument(); + }); + + it("should show non-admin empty state message when no agents and not admin", () => { + renderWithProviders( + + ); + expect( + screen.getByText("No agents found. Contact an admin to create agents.") + ).toBeInTheDocument(); + }); + + it("should call onAgentClick when a card is clicked", async () => { + const onAgentClick = vi.fn(); + renderWithProviders( + + ); + const { default: userEvent } = await import("@testing-library/user-event"); + const user = userEvent.setup(); + await user.click(screen.getByTestId("agent-card-agent-1")); + expect(onAgentClick).toHaveBeenCalledWith("agent-1"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/RateLimitTypeFormItem.test.tsx b/ui/litellm-dashboard/src/components/common_components/RateLimitTypeFormItem.test.tsx new file mode 100644 index 0000000000..49b8028c51 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/RateLimitTypeFormItem.test.tsx @@ -0,0 +1,61 @@ +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import { Form } from "antd"; +import React from "react"; +import { RateLimitTypeFormItem } from "./RateLimitTypeFormItem"; + +const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
{children}
+); + +describe("RateLimitTypeFormItem", () => { + it("should render", () => { + renderWithProviders( + + + + ); + expect(screen.getByText(/TPM Rate Limit Type/)).toBeInTheDocument(); + }); + + it("should display TPM label for tpm type", () => { + renderWithProviders( + + + + ); + expect(screen.getByText(/TPM Rate Limit Type/)).toBeInTheDocument(); + }); + + it("should display RPM label for rpm type", () => { + renderWithProviders( + + + + ); + expect(screen.getByText(/RPM Rate Limit Type/)).toBeInTheDocument(); + }); + + it("should show the select placeholder by default", () => { + renderWithProviders( + + + + ); + expect(screen.getByText("Select rate limit type")).toBeInTheDocument(); + }); + + it("should call onChange when provided", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithProviders( + + + + ); + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByText("Guaranteed throughput")); + expect(onChange).toHaveBeenCalledWith("guaranteed_throughput"); + }); +}); From bd2502eeaf99111d17442929f68542639417719c Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 20:34:15 -0700 Subject: [PATCH 02/18] [Feature] /v2/team/list: Add org admin access control, members_count, and indexes Add org admin support to /v2/team/list so org admins can list teams within their organizations instead of getting 401. Also enrich the response with members_count and add missing indexes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../litellm_proxy_extras/schema.prisma | 4 + .../management_endpoints/team_endpoints.py | 132 +++++++++++++++--- litellm/proxy/schema.prisma | 4 + .../management_endpoints/team_endpoints.py | 9 +- schema.prisma | 4 + 5 files changed, 136 insertions(+), 17 deletions(-) diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index ce79c2b3d5..a2c8329540 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -146,6 +146,10 @@ model LiteLLM_TeamTable { litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id]) object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) projects LiteLLM_ProjectTable[] + + @@index([organization_id]) + @@index([team_alias]) + @@index([created_at]) } // Projects sit between teams and keys for use-case management diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index d83ceb1b09..a7ee340282 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -101,6 +101,7 @@ from litellm.types.proxy.management_endpoints.team_endpoints import ( BulkTeamMemberAddRequest, BulkTeamMemberAddResponse, GetTeamMemberPermissionsResponse, + TeamListItem, TeamListResponse, TeamMemberAddResult, UpdateTeamMemberPermissionsRequest, @@ -3206,6 +3207,39 @@ async def list_available_teams( return available_teams_correct_type +async def _get_org_admin_org_ids( + user_id: str, + prisma_client: Any, + user_api_key_cache: Any, + proxy_logging_obj: Any, +) -> Optional[List[str]]: + """ + Return the list of organization IDs where the user is an org admin. + Returns None if the user is not an org admin of any organization or if + the user cannot be found. + """ + try: + caller_user = await get_user_object( + user_id=user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + except Exception: + return None + + if caller_user is None: + return None + + org_ids = [ + m.organization_id + for m in (caller_user.organization_memberships or []) + if m.user_role == LitellmUserRoles.ORG_ADMIN.value + ] + return org_ids if org_ids else None + + async def _build_team_list_where_conditions( prisma_client: PrismaClient, team_id: Optional[str], @@ -3213,6 +3247,7 @@ async def _build_team_list_where_conditions( organization_id: Optional[str], user_id: Optional[str], use_deleted_table: bool, + org_admin_org_ids: Optional[List[str]] = None, ) -> Dict[str, Any]: """Build where conditions for team list query.""" where_conditions: Dict[str, Any] = {} @@ -3227,7 +3262,14 @@ async def _build_team_list_where_conditions( } if organization_id: - where_conditions["organization_id"] = organization_id + # If a list is passed (org admin viewing multiple orgs), use IN + if isinstance(organization_id, list): + where_conditions["organization_id"] = {"in": organization_id} + else: + where_conditions["organization_id"] = organization_id + elif org_admin_org_ids is not None: + # Org admin without explicit org filter: scope to their orgs + where_conditions["organization_id"] = {"in": org_admin_org_ids} if user_id: try: @@ -3347,7 +3389,11 @@ async def list_team_v2( status: Optional[str] Filter by status. Currently supports "deleted" to query deleted teams. """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if prisma_client is None: raise HTTPException( @@ -3355,20 +3401,58 @@ async def list_team_v2( detail={"error": f"No db connected. prisma client={prisma_client}"}, ) - if not allowed_route_check_inside_route( - user_api_key_dict=user_api_key_dict, requested_user_id=user_id - ): - raise HTTPException( - status_code=401, - detail={ - "error": "Only admin users can query all teams/other teams. Your user role={}".format( - user_api_key_dict.user_role - ) - }, + # --- Access control --- + # Proxy admins and admin viewers can query any teams. + # Org admins can query teams within their organizations. + # Regular users can only query their own teams. + is_proxy_admin = _user_has_admin_view(user_api_key_dict) + org_admin_org_ids: Optional[List[str]] = None + + if not is_proxy_admin: + # First check if the standard route check passes (user querying own data) + passes_route_check = allowed_route_check_inside_route( + user_api_key_dict=user_api_key_dict, requested_user_id=user_id ) - if user_id is None and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - user_id = user_api_key_dict.user_id + if not passes_route_check: + # Standard check failed — see if the caller is an org admin + if user_api_key_dict.user_id: + org_admin_org_ids = await _get_org_admin_org_ids( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + if org_admin_org_ids is None: + # Not an org admin either — reject + raise HTTPException( + status_code=401, + detail={ + "error": "Only admin users can query all teams/other teams. Your user role={}".format( + user_api_key_dict.user_role + ) + }, + ) + + if org_admin_org_ids is not None: + # Org admin: validate org_id filter if provided + if organization_id and organization_id not in org_admin_org_ids: + raise HTTPException( + status_code=403, + detail={ + "error": "You can only view teams within your organizations." + }, + ) + verbose_proxy_logger.debug( + "list_team_v2: org admin access for user=%s, org_ids=%s", + user_api_key_dict.user_id, + org_admin_org_ids, + ) + elif not is_proxy_admin: + # Regular user — auto-inject caller's user_id + if user_id is None: + user_id = user_api_key_dict.user_id if status is not None and status != "deleted": raise HTTPException( @@ -3391,6 +3475,7 @@ async def list_team_v2( organization_id=organization_id, user_id=user_id, use_deleted_table=use_deleted_table, + org_admin_org_ids=org_admin_org_ids, ) # Build order_by conditions @@ -3428,8 +3513,23 @@ async def list_team_v2( # Calculate total pages total_pages = -(-total_count // page_size) # Ceiling division - # Convert Prisma models to Pydantic models, preserving deleted fields when applicable - team_list = _convert_teams_to_response(teams, use_deleted_table) + # Convert Prisma models to response models with members_count + team_list: List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] = [] + for team in teams: + try: + team_dict = team.model_dump() + except Exception: + team_dict = team.dict() + + if use_deleted_table: + team_list.append(LiteLLM_DeletedTeamTable(**team_dict)) + else: + members_with_roles = team_dict.get("members_with_roles") + if not isinstance(members_with_roles, list): + members_with_roles = [] + team_dict["members_with_roles"] = members_with_roles + members_count = len(members_with_roles) + team_list.append(TeamListItem(**team_dict, members_count=members_count)) return { "teams": team_list, diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index b68872e2ed..46be6b31e1 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -146,6 +146,10 @@ model LiteLLM_TeamTable { litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id]) object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) projects LiteLLM_ProjectTable[] + + @@index([organization_id]) + @@index([team_alias]) + @@index([created_at]) } // Projects sit between teams and keys for use-case management diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 77816fa78c..a69d69c623 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel @@ -43,10 +44,16 @@ class UpdateTeamMemberPermissionsRequest(BaseModel): team_member_permissions: List[str] +class TeamListItem(LiteLLM_TeamTable): + """A team item in the paginated list response, enriched with computed fields.""" + + members_count: int = 0 + + class TeamListResponse(BaseModel): """Response to get the list of teams""" - teams: List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] + teams: List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] total: int page: int page_size: int diff --git a/schema.prisma b/schema.prisma index 939f1eb0f4..fde9a466a2 100644 --- a/schema.prisma +++ b/schema.prisma @@ -146,6 +146,10 @@ model LiteLLM_TeamTable { litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id]) object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) projects LiteLLM_ProjectTable[] + + @@index([organization_id]) + @@index([team_alias]) + @@index([created_at]) } // Projects sit between teams and keys for use-case management From 5e2fb72f42ef10e12e8d29e9120398db9b395fd2 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 20:50:58 -0700 Subject: [PATCH 03/18] fix: address review feedback on v2/team/list - Fix org admin own-query regression: always check org admin status before the standard route check so own-queries see all org teams - Clear user_id when org admin is detected so org scope replaces user-membership scope - Remove dead isinstance(organization_id, list) branch - Remove unused datetime import - Remove orphaned _convert_teams_to_response helper Co-Authored-By: Claude Opus 4.6 (1M context) --- .../management_endpoints/team_endpoints.py | 79 +++++++------------ .../management_endpoints/team_endpoints.py | 1 - 2 files changed, 27 insertions(+), 53 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index a7ee340282..35122259c3 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3262,11 +3262,7 @@ async def _build_team_list_where_conditions( } if organization_id: - # If a list is passed (org admin viewing multiple orgs), use IN - if isinstance(organization_id, list): - where_conditions["organization_id"] = {"in": organization_id} - else: - where_conditions["organization_id"] = organization_id + where_conditions["organization_id"] = organization_id elif org_admin_org_ids is not None: # Org admin without explicit org filter: scope to their orgs where_conditions["organization_id"] = {"in": org_admin_org_ids} @@ -3304,27 +3300,6 @@ async def _build_team_list_where_conditions( return where_conditions -def _convert_teams_to_response( - teams: List[Any], use_deleted_table: bool -) -> List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]: - """Convert Prisma models to Pydantic models.""" - team_list: List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] = [] - if teams: - for team in teams: - # Convert Prisma model to dict (supports both Pydantic v1 and v2) - try: - team_dict = team.model_dump() - except Exception: - # Fallback for Pydantic v1 compatibility - team_dict = team.dict() - if use_deleted_table: - # Use deleted team type to preserve deleted_at, deleted_by, etc. - team_list.append(LiteLLM_DeletedTeamTable(**team_dict)) - else: - team_list.append(LiteLLM_TeamTable(**team_dict)) - return team_list - - @router.get( "/v2/team/list", tags=["team management"], @@ -3409,31 +3384,15 @@ async def list_team_v2( org_admin_org_ids: Optional[List[str]] = None if not is_proxy_admin: - # First check if the standard route check passes (user querying own data) - passes_route_check = allowed_route_check_inside_route( - user_api_key_dict=user_api_key_dict, requested_user_id=user_id - ) - - if not passes_route_check: - # Standard check failed — see if the caller is an org admin - if user_api_key_dict.user_id: - org_admin_org_ids = await _get_org_admin_org_ids( - user_id=user_api_key_dict.user_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - ) - - if org_admin_org_ids is None: - # Not an org admin either — reject - raise HTTPException( - status_code=401, - detail={ - "error": "Only admin users can query all teams/other teams. Your user role={}".format( - user_api_key_dict.user_role - ) - }, - ) + # Always check org admin status so that even own-queries see + # the full set of organisation teams, not just direct memberships. + if user_api_key_dict.user_id: + org_admin_org_ids = await _get_org_admin_org_ids( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) if org_admin_org_ids is not None: # Org admin: validate org_id filter if provided @@ -3444,12 +3403,28 @@ async def list_team_v2( "error": "You can only view teams within your organizations." }, ) + # Org admin scope replaces user_id scope — the org filter + # already returns all teams in their orgs, so we don't also + # restrict by the user's direct team memberships. + user_id = None verbose_proxy_logger.debug( "list_team_v2: org admin access for user=%s, org_ids=%s", user_api_key_dict.user_id, org_admin_org_ids, ) - elif not is_proxy_admin: + else: + # Not an org admin — fall back to standard route check + if not allowed_route_check_inside_route( + user_api_key_dict=user_api_key_dict, requested_user_id=user_id + ): + raise HTTPException( + status_code=401, + detail={ + "error": "Only admin users can query all teams/other teams. Your user role={}".format( + user_api_key_dict.user_role + ) + }, + ) # Regular user — auto-inject caller's user_id if user_id is None: user_id = user_api_key_dict.user_id diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index a69d69c623..5055a65783 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel From 1998571d94b366c9525f84ac96622242f4a16353 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 21:03:35 -0700 Subject: [PATCH 04/18] fix: address second review round on v2/team/list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _get_org_admin_org_ids: catch only ValueError (user not found) instead of bare Exception — DB errors now propagate as 500s instead of silently demoting org admins to regular users - _build_team_list_where_conditions: return None (not a sentinel string) when user has no team memberships; list_team_v2 short-circuits to empty response without hitting the DB - Org admin + team_id + user_id: use exact team_id match with org scope instead of OR expansion that effectively ignored the team_id filter - Org admin + user_id (no team_id): OR(org teams, direct memberships) now matches legacy _authorize_and_filter_teams behaviour Co-Authored-By: Claude Opus 4.6 (1M context) --- .../management_endpoints/team_endpoints.py | 82 ++++++++++++------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 35122259c3..b32206a454 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3226,7 +3226,8 @@ async def _get_org_admin_org_ids( user_id_upsert=False, proxy_logging_obj=proxy_logging_obj, ) - except Exception: + except ValueError: + # get_user_object raises ValueError when the user doesn't exist return None if caller_user is None: @@ -3248,8 +3249,13 @@ async def _build_team_list_where_conditions( user_id: Optional[str], use_deleted_table: bool, org_admin_org_ids: Optional[List[str]] = None, -) -> Dict[str, Any]: - """Build where conditions for team list query.""" +) -> Optional[Dict[str, Any]]: + """ + Build where conditions for team list query. + + Returns None when the query is guaranteed to yield no results (e.g. user + has no team memberships), allowing the caller to skip the DB round-trip. + """ where_conditions: Dict[str, Any] = {} if team_id: @@ -3263,39 +3269,50 @@ async def _build_team_list_where_conditions( if organization_id: where_conditions["organization_id"] = organization_id - elif org_admin_org_ids is not None: - # Org admin without explicit org filter: scope to their orgs + elif org_admin_org_ids is not None and not user_id: + # Org admin without explicit org or user filter: scope to their orgs where_conditions["organization_id"] = {"in": org_admin_org_ids} if user_id: - try: - user_object = await prisma_client.db.litellm_usertable.find_unique( - where={"user_id": user_id} - ) - except Exception: - raise HTTPException( - status_code=404, - detail={"error": f"User not found, passed user_id={user_id}"}, - ) + user_object = await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_id} + ) if user_object is None: raise HTTPException( status_code=404, detail={"error": f"User not found, passed user_id={user_id}"}, ) user_object_correct_type = LiteLLM_UserTable(**user_object.model_dump()) + user_team_ids = user_object_correct_type.teams or [] if use_deleted_table: where_conditions["members"] = {"has": user_id} - else: - if team_id is None: - where_conditions["team_id"] = {"in": user_object_correct_type.teams} - elif team_id in user_object_correct_type.teams: - where_conditions["team_id"] = team_id + elif org_admin_org_ids is not None: + # Org admin with user_id filter: show teams in their orgs + # OR teams the user is a direct member of (matches legacy + # _authorize_and_filter_teams behaviour). + # When team_id is also provided, the exact match is already in + # where_conditions and the org scope just needs to be added — + # no OR expansion needed. + if team_id is not None: + where_conditions["organization_id"] = {"in": org_admin_org_ids} + elif user_team_ids: + org_condition: Dict[str, Any] = {"organization_id": {"in": org_admin_org_ids}} + where_conditions["OR"] = [org_condition, {"team_id": {"in": user_team_ids}}] else: - raise HTTPException( - status_code=404, - detail={"error": f"User is not a member of team_id={team_id}"}, - ) + where_conditions["organization_id"] = {"in": org_admin_org_ids} + else: + if not user_team_ids: + return None # no memberships — skip the DB query + elif team_id is not None: + # team_id exact-match already in where_conditions; verify membership + if team_id not in user_team_ids: + raise HTTPException( + status_code=404, + detail={"error": f"User is not a member of team_id={team_id}"}, + ) + else: + where_conditions["team_id"] = {"in": user_team_ids} return where_conditions @@ -3403,14 +3420,11 @@ async def list_team_v2( "error": "You can only view teams within your organizations." }, ) - # Org admin scope replaces user_id scope — the org filter - # already returns all teams in their orgs, so we don't also - # restrict by the user's direct team memberships. - user_id = None verbose_proxy_logger.debug( - "list_team_v2: org admin access for user=%s, org_ids=%s", + "list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s", user_api_key_dict.user_id, org_admin_org_ids, + user_id, ) else: # Not an org admin — fall back to standard route check @@ -3442,7 +3456,8 @@ async def list_team_v2( # Calculate skip and take for pagination skip = (page - 1) * page_size - # Build where conditions based on provided parameters + # Build where conditions based on provided parameters. + # Returns None when the query is guaranteed to yield no results. where_conditions = await _build_team_list_where_conditions( prisma_client=prisma_client, team_id=team_id, @@ -3453,6 +3468,15 @@ async def list_team_v2( org_admin_org_ids=org_admin_org_ids, ) + if where_conditions is None: + return { + "teams": [], + "total": 0, + "page": page, + "page_size": page_size, + "total_pages": 0, + } + # Build order_by conditions valid_sort_columns = ["team_id", "team_alias", "created_at"] order_by = None From 0485a1859a94520ff55148aeab313a102d2b50be Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 21:19:42 -0700 Subject: [PATCH 05/18] fix: use get_user_object helper, preserve caller org_id filter - Replace raw find_unique with get_user_object in _build_team_list_where_conditions for cache/metrics consistency - Remove over-complex OR clause for org admin + user_id: when user_id is provided, filter by that user's direct team memberships (same as regular users) since the access control gate already verified the org admin's authority - Preserve caller-supplied organization_id instead of overwriting with org_admin_org_ids - Update test mock to match get_user_object call path Co-Authored-By: Claude Opus 4.6 (1M context) --- .../management_endpoints/team_endpoints.py | 41 +++++++------- .../test_team_endpoints.py | 55 +++++++++++-------- 2 files changed, 53 insertions(+), 43 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index b32206a454..531cd67589 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3249,6 +3249,8 @@ async def _build_team_list_where_conditions( user_id: Optional[str], use_deleted_table: bool, org_admin_org_ids: Optional[List[str]] = None, + user_api_key_cache: Optional[Any] = None, + proxy_logging_obj: Optional[Any] = None, ) -> Optional[Dict[str, Any]]: """ Build where conditions for team list query. @@ -3274,34 +3276,33 @@ async def _build_team_list_where_conditions( where_conditions["organization_id"] = {"in": org_admin_org_ids} if user_id: - user_object = await prisma_client.db.litellm_usertable.find_unique( - where={"user_id": user_id} - ) - if user_object is None: + try: + user_object_correct_type = await get_user_object( + user_id=user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + except ValueError: + raise HTTPException( + status_code=404, + detail={"error": f"User not found, passed user_id={user_id}"}, + ) + if user_object_correct_type is None: raise HTTPException( status_code=404, detail={"error": f"User not found, passed user_id={user_id}"}, ) - user_object_correct_type = LiteLLM_UserTable(**user_object.model_dump()) user_team_ids = user_object_correct_type.teams or [] if use_deleted_table: where_conditions["members"] = {"has": user_id} - elif org_admin_org_ids is not None: - # Org admin with user_id filter: show teams in their orgs - # OR teams the user is a direct member of (matches legacy - # _authorize_and_filter_teams behaviour). - # When team_id is also provided, the exact match is already in - # where_conditions and the org scope just needs to be added — - # no OR expansion needed. - if team_id is not None: - where_conditions["organization_id"] = {"in": org_admin_org_ids} - elif user_team_ids: - org_condition: Dict[str, Any] = {"organization_id": {"in": org_admin_org_ids}} - where_conditions["OR"] = [org_condition, {"team_id": {"in": user_team_ids}}] - else: - where_conditions["organization_id"] = {"in": org_admin_org_ids} else: + # When user_id is provided, filter by that user's direct team + # memberships. For org admins the access control gate in + # list_team_v2 already verified the caller's authority — the + # filter logic is the same as for regular users. if not user_team_ids: return None # no memberships — skip the DB query elif team_id is not None: @@ -3466,6 +3467,8 @@ async def list_team_v2( user_id=user_id, use_deleted_table=use_deleted_table, org_admin_org_ids=org_admin_org_ids, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, ) if where_conditions is None: diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 41a724c271..122ac749f2 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -2142,19 +2142,21 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams(): user_id="non_admin_user_123", ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client: + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \ + patch("litellm.proxy.proxy_server.user_api_key_cache"), \ + patch("litellm.proxy.proxy_server.proxy_logging_obj"): # Mock prisma client and database operations mock_db = Mock() mock_prisma_client.db = mock_db - - # Mock user lookup - mock_user_object = Mock() - mock_user_object.model_dump.return_value = { - "user_id": "non_admin_user_123", - "teams": ["team_1", "team_2"], - } - mock_db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user_object) - + + # Mock get_user_object to return a user with teams + from litellm.proxy._types import LiteLLM_UserTable + + mock_user = LiteLLM_UserTable( + user_id="non_admin_user_123", + teams=["team_1", "team_2"], + ) + # Mock team lookup mock_teams = [ Mock(model_dump=lambda: {"team_id": "team_1", "team_alias": "Team 1"}), @@ -2163,21 +2165,26 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams(): mock_db.litellm_teamtable.find_many = AsyncMock(return_value=mock_teams) mock_db.litellm_teamtable.count = AsyncMock(return_value=2) - # Should NOT raise an exception - result = await list_team_v2( - http_request=mock_request, - user_id="non_admin_user_123", # Non-admin querying their own teams - user_api_key_dict=mock_user_api_key_dict_non_admin, - team_id=None, - page=1, - page_size=10, - status=None, - ) + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=mock_user, + ): + # Should NOT raise an exception + result = await list_team_v2( + http_request=mock_request, + user_id="non_admin_user_123", # Non-admin querying their own teams + user_api_key_dict=mock_user_api_key_dict_non_admin, + team_id=None, + page=1, + page_size=10, + status=None, + ) - # Should return results without error - assert "teams" in result - assert "total" in result - assert result["total"] == 2 + # Should return results without error + assert "teams" in result + assert "total" in result + assert result["total"] == 2 @pytest.mark.asyncio From 41a7747e8cc99b133dd72ac6de6f55aaa9708e0d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 21:56:57 -0700 Subject: [PATCH 06/18] fix: document org scope behavior, fix test mocks, add org admin tests - Document intentional legacy-matching behavior: when user_id is provided to an org admin, no org filter is applied (returns all of that user's teams across all orgs, same as legacy endpoint) - Fix two existing security tests to properly patch user_api_key_cache, proxy_logging_obj, and get_user_object instead of relying on incidental error handling - Add three new org admin test cases: - Org admin sees org-scoped teams (200 with correct where clause) - Org admin rejected when filtering by other org (403) - Org admin with user_id filter returns target user's teams Co-Authored-By: Claude Opus 4.6 (1M context) --- .../management_endpoints/team_endpoints.py | 7 +- .../test_team_endpoints.py | 281 +++++++++++++++++- 2 files changed, 280 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 531cd67589..e7cd6c1748 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3272,7 +3272,12 @@ async def _build_team_list_where_conditions( if organization_id: where_conditions["organization_id"] = organization_id elif org_admin_org_ids is not None and not user_id: - # Org admin without explicit org or user filter: scope to their orgs + # Org admin without explicit org or user filter: scope to their orgs. + # NOTE: when user_id is provided, no org filter is applied — the + # query returns all teams the target user belongs to across all + # organisations. This matches the legacy /team/list behaviour in + # _authorize_and_filter_teams which fetches direct-membership teams + # without an org constraint. where_conditions["organization_id"] = {"in": org_admin_org_ids} if user_id: diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 122ac749f2..c325b0b6fc 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -2062,7 +2062,14 @@ async def test_list_team_v2_security_check_non_admin_user(): user_id="non_admin_user_123", ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client: + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \ + patch("litellm.proxy.proxy_server.user_api_key_cache"), \ + patch("litellm.proxy.proxy_server.proxy_logging_obj"), \ + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=None, + ): mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client # Should raise HTTPException with 401 status @@ -2103,7 +2110,14 @@ async def test_list_team_v2_security_check_non_admin_user_other_user(): user_id="non_admin_user_123", ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client: + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \ + patch("litellm.proxy.proxy_server.user_api_key_cache"), \ + patch("litellm.proxy.proxy_server.proxy_logging_obj"), \ + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=None, + ): mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client # Should raise HTTPException with 401 status @@ -2300,27 +2314,280 @@ async def test_list_team_v2_with_status_deleted(): assert len(result["teams"]) == 2 +@pytest.mark.asyncio +async def test_list_team_v2_org_admin_sees_org_teams(): + """ + Test that an org admin (internal_user role with org_admin membership) + can list teams scoped to their organisations without getting a 401. + """ + from datetime import datetime + from unittest.mock import AsyncMock, Mock, patch + + from fastapi import Request + + from litellm.proxy._types import ( + LiteLLM_OrganizationMembershipTable, + LiteLLM_UserTable, + LitellmUserRoles, + UserAPIKeyAuth, + ) + from litellm.proxy.management_endpoints.team_endpoints import list_team_v2 + + mock_request = Mock(spec=Request) + mock_user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="org_admin_user", + ) + + mock_user = LiteLLM_UserTable( + user_id="org_admin_user", + teams=[], + organization_memberships=[ + LiteLLM_OrganizationMembershipTable( + user_id="org_admin_user", + organization_id="org_A", + user_role="org_admin", + spend=0.0, + created_at=datetime.now(), + updated_at=datetime.now(), + ), + ], + ) + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \ + patch("litellm.proxy.proxy_server.user_api_key_cache"), \ + patch("litellm.proxy.proxy_server.proxy_logging_obj"), \ + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=mock_user, + ): + mock_db = Mock() + mock_prisma.db = mock_db + + mock_team = Mock() + mock_team.model_dump.return_value = { + "team_id": "team_in_org_A", + "team_alias": "Org A Team", + "organization_id": "org_A", + "members_with_roles": [{"user_id": "u1", "role": "user"}], + } + mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) + mock_db.litellm_teamtable.count = AsyncMock(return_value=1) + + result = await list_team_v2( + http_request=mock_request, + user_id=None, + organization_id=None, + team_id=None, + team_alias=None, + user_api_key_dict=mock_user_api_key_dict, + page=1, + page_size=10, + sort_by=None, + sort_order="asc", + status=None, + ) + + assert result["total"] == 1 + assert len(result["teams"]) == 1 + assert result["teams"][0].members_count == 1 + + # Verify org-scoped where clause + where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"] + assert where["organization_id"] == {"in": ["org_A"]} + + +@pytest.mark.asyncio +async def test_list_team_v2_org_admin_cannot_view_other_orgs(): + """ + Test that an org admin is rejected with 403 when filtering by an + organisation they do not administer. + """ + from datetime import datetime + from unittest.mock import AsyncMock, Mock, patch + + from fastapi import HTTPException, Request + + from litellm.proxy._types import ( + LiteLLM_OrganizationMembershipTable, + LiteLLM_UserTable, + LitellmUserRoles, + UserAPIKeyAuth, + ) + from litellm.proxy.management_endpoints.team_endpoints import list_team_v2 + + mock_request = Mock(spec=Request) + mock_user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="org_admin_user", + ) + + mock_user = LiteLLM_UserTable( + user_id="org_admin_user", + teams=[], + organization_memberships=[ + LiteLLM_OrganizationMembershipTable( + user_id="org_admin_user", + organization_id="org_A", + user_role="org_admin", + spend=0.0, + created_at=datetime.now(), + updated_at=datetime.now(), + ), + ], + ) + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \ + patch("litellm.proxy.proxy_server.user_api_key_cache"), \ + patch("litellm.proxy.proxy_server.proxy_logging_obj"), \ + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=mock_user, + ): + mock_prisma.db = Mock() + + with pytest.raises(HTTPException) as exc_info: + await list_team_v2( + http_request=mock_request, + user_id=None, + organization_id="org_B", # not their org + team_id=None, + team_alias=None, + user_api_key_dict=mock_user_api_key_dict, + page=1, + page_size=10, + sort_by=None, + sort_order="asc", + status=None, + ) + + assert exc_info.value.status_code == 403 + assert "only view teams within your organizations" in str( + exc_info.value.detail + ).lower() + + +@pytest.mark.asyncio +async def test_list_team_v2_org_admin_with_user_id_returns_user_teams(): + """ + Test that an org admin passing user_id gets that user's direct team + memberships (not all org teams). + """ + from datetime import datetime + from unittest.mock import AsyncMock, Mock, patch + + from fastapi import Request + + from litellm.proxy._types import ( + LiteLLM_OrganizationMembershipTable, + LiteLLM_UserTable, + LitellmUserRoles, + UserAPIKeyAuth, + ) + from litellm.proxy.management_endpoints.team_endpoints import list_team_v2 + + mock_request = Mock(spec=Request) + mock_user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="org_admin_user", + ) + + mock_org_admin = LiteLLM_UserTable( + user_id="org_admin_user", + teams=["team_1"], + organization_memberships=[ + LiteLLM_OrganizationMembershipTable( + user_id="org_admin_user", + organization_id="org_A", + user_role="org_admin", + spend=0.0, + created_at=datetime.now(), + updated_at=datetime.now(), + ), + ], + ) + + # The target user whose teams we want to list + mock_target_user = LiteLLM_UserTable( + user_id="target_user", + teams=["team_X", "team_Y"], + ) + + call_count = 0 + + async def mock_get_user_object(**kwargs): + nonlocal call_count + call_count += 1 + # First call: org admin lookup in list_team_v2 + # Second call: target user lookup in _build_team_list_where_conditions + if call_count == 1: + return mock_org_admin + return mock_target_user + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \ + patch("litellm.proxy.proxy_server.user_api_key_cache"), \ + patch("litellm.proxy.proxy_server.proxy_logging_obj"), \ + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + side_effect=mock_get_user_object, + ): + mock_db = Mock() + mock_prisma.db = mock_db + + mock_team = Mock() + mock_team.model_dump.return_value = { + "team_id": "team_X", + "team_alias": "Target Team", + "members_with_roles": [{"user_id": "target_user", "role": "user"}], + } + mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) + mock_db.litellm_teamtable.count = AsyncMock(return_value=1) + + result = await list_team_v2( + http_request=mock_request, + user_id="target_user", + organization_id=None, + team_id=None, + team_alias=None, + user_api_key_dict=mock_user_api_key_dict, + page=1, + page_size=10, + sort_by=None, + sort_order="asc", + status=None, + ) + + assert result["total"] == 1 + + # Verify the where clause filters by user's teams, not org scope + where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"] + assert where["team_id"] == {"in": ["team_X", "team_Y"]} + assert "organization_id" not in where + + @pytest.mark.asyncio async def test_list_team_v2_with_invalid_status(): """ Test that invalid status parameter raises HTTPException. """ from unittest.mock import Mock, patch - + from fastapi import HTTPException, Request - + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.management_endpoints.team_endpoints import list_team_v2 - + # Mock request mock_request = Mock(spec=Request) - + # Mock admin user mock_user_api_key_dict_admin = UserAPIKeyAuth( user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user_123", ) - + mock_prisma_client = Mock() # Mock prisma_client to be non-None From 2c874a7f3cb25a384336cb4061cebdce51457866 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 23:00:05 -0700 Subject: [PATCH 07/18] fix: prevent body 'key' field from overriding Authorization header in auth On /key/block, /key/unblock, and /key/update, the request body 'key' field could contaminate the api_key Security dependency, causing the auth layer to authenticate against the target key instead of the caller's bearer token. This returned 401 for a nonexistent body key even when the Authorization header contained a valid master key. Added a guard in user_api_key_auth that re-reads the Authorization header directly from the request, ensuring the header is always the authoritative source for authentication. Co-Authored-By: Claude Opus 4.6 (1M context) --- litellm/proxy/auth/user_api_key_auth.py | 11 +++ .../test_key_management_endpoints.py | 85 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 376048e7a1..724184282f 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1535,6 +1535,17 @@ async def user_api_key_auth( Parent function to authenticate user api key / jwt token. """ + # Guard: re-read the Authorization header directly from the request. + # On endpoints whose request body contains a "key" field (e.g. /key/block, + # /key/unblock, /key/update), the body value can contaminate the api_key + # Security dependency, causing the auth layer to authenticate against the + # target key instead of the caller's bearer token. + _raw_auth_header = request.headers.get( + SpecialHeaders.openai_authorization.value + ) + if _raw_auth_header is not None: + api_key = _raw_auth_header + request_data = await _read_request_body(request=request) request_data = populate_request_with_path_params( request_data=request_data, request=request diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index cfc16808af..f1fb973a79 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -7185,3 +7185,88 @@ def test_update_key_request_has_organization_id(): # Also verify it defaults to None data_no_org = UpdateKeyRequest(key="sk-test-key") assert data_no_org.organization_id is None + + +@pytest.mark.asyncio +async def test_body_key_does_not_override_authorization_header(monkeypatch): + """ + Verify that the request body 'key' field (used by /key/block, /key/unblock, + /key/update) does not contaminate the api_key used for authentication. + + Regression test: previously, sending a request like + POST /key/block + Authorization: Bearer + Body: {"key": "sk-does-not-exist"} + would authenticate against "sk-does-not-exist" instead of the master key, + returning a 401 for a non-existent key even though the caller had a valid + master key in the Authorization header. + """ + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + master_key = "sk-master-test-key-1234" + + # Mock proxy_server globals + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", master_key) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MagicMock()) + monkeypatch.setattr( + "litellm.proxy.proxy_server.general_settings", {} + ) + monkeypatch.setattr("litellm.proxy.proxy_server.jwt_handler", MagicMock()) + monkeypatch.setattr( + "litellm.proxy.proxy_server.litellm_proxy_admin_name", + "default_user_id", + ) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_model_list", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + monkeypatch.setattr("litellm.proxy.proxy_server.user_custom_auth", None) + monkeypatch.setattr( + "litellm.proxy.proxy_server.open_telemetry_logger", None + ) + monkeypatch.setattr( + "litellm.proxy.proxy_server.model_max_budget_limiter", MagicMock() + ) + mock_user_api_key_cache = MagicMock() + mock_user_api_key_cache.async_get_cache = AsyncMock(return_value=None) + mock_user_api_key_cache.get_cache = MagicMock(return_value=None) + monkeypatch.setattr( + "litellm.proxy.proxy_server.user_api_key_cache", + mock_user_api_key_cache, + ) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", MagicMock() + ) + + # Build a mock request that simulates: + # POST /key/block + # Authorization: Bearer + # Body: {"key": "sk-does-not-exist"} + mock_request = MagicMock() + mock_request.headers = { + "Authorization": f"Bearer {master_key}", + "Content-Type": "application/json", + } + mock_request.url.path = "/key/block" + mock_request.method = "POST" + mock_request.query_params = {} + mock_request.scope = { + "type": "http", + "path": "/key/block", + # Simulate a pre-parsed body with a 'key' field that differs from the master key + "parsed_body": ( + ("key",), + {"key": "sk-does-not-exist"}, + ), + } + mock_request.state = MagicMock() + + # Call user_api_key_auth the way FastAPI does. If the body 'key' field + # contaminates the api_key parameter, pass it explicitly to prove the + # guard works. + result = await user_api_key_auth( + request=mock_request, + api_key="sk-does-not-exist", # Simulates the contaminated value + ) + + # Auth should succeed — the guard re-reads the Authorization header + assert result is not None + assert result.user_role == LitellmUserRoles.PROXY_ADMIN From b0990e2684d8379e740e2f608e8f21a0cb491404 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 23:02:38 -0700 Subject: [PATCH 08/18] Revert "fix: prevent body 'key' field from overriding Authorization header in auth" This reverts commit 2c874a7f3cb25a384336cb4061cebdce51457866. --- litellm/proxy/auth/user_api_key_auth.py | 11 --- .../test_key_management_endpoints.py | 85 ------------------- 2 files changed, 96 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 724184282f..376048e7a1 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1535,17 +1535,6 @@ async def user_api_key_auth( Parent function to authenticate user api key / jwt token. """ - # Guard: re-read the Authorization header directly from the request. - # On endpoints whose request body contains a "key" field (e.g. /key/block, - # /key/unblock, /key/update), the body value can contaminate the api_key - # Security dependency, causing the auth layer to authenticate against the - # target key instead of the caller's bearer token. - _raw_auth_header = request.headers.get( - SpecialHeaders.openai_authorization.value - ) - if _raw_auth_header is not None: - api_key = _raw_auth_header - request_data = await _read_request_body(request=request) request_data = populate_request_with_path_params( request_data=request_data, request=request diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index f1fb973a79..cfc16808af 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -7185,88 +7185,3 @@ def test_update_key_request_has_organization_id(): # Also verify it defaults to None data_no_org = UpdateKeyRequest(key="sk-test-key") assert data_no_org.organization_id is None - - -@pytest.mark.asyncio -async def test_body_key_does_not_override_authorization_header(monkeypatch): - """ - Verify that the request body 'key' field (used by /key/block, /key/unblock, - /key/update) does not contaminate the api_key used for authentication. - - Regression test: previously, sending a request like - POST /key/block - Authorization: Bearer - Body: {"key": "sk-does-not-exist"} - would authenticate against "sk-does-not-exist" instead of the master key, - returning a 401 for a non-existent key even though the caller had a valid - master key in the Authorization header. - """ - from litellm.proxy.auth.user_api_key_auth import user_api_key_auth - - master_key = "sk-master-test-key-1234" - - # Mock proxy_server globals - monkeypatch.setattr("litellm.proxy.proxy_server.master_key", master_key) - monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MagicMock()) - monkeypatch.setattr( - "litellm.proxy.proxy_server.general_settings", {} - ) - monkeypatch.setattr("litellm.proxy.proxy_server.jwt_handler", MagicMock()) - monkeypatch.setattr( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", - "default_user_id", - ) - monkeypatch.setattr("litellm.proxy.proxy_server.llm_model_list", None) - monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) - monkeypatch.setattr("litellm.proxy.proxy_server.user_custom_auth", None) - monkeypatch.setattr( - "litellm.proxy.proxy_server.open_telemetry_logger", None - ) - monkeypatch.setattr( - "litellm.proxy.proxy_server.model_max_budget_limiter", MagicMock() - ) - mock_user_api_key_cache = MagicMock() - mock_user_api_key_cache.async_get_cache = AsyncMock(return_value=None) - mock_user_api_key_cache.get_cache = MagicMock(return_value=None) - monkeypatch.setattr( - "litellm.proxy.proxy_server.user_api_key_cache", - mock_user_api_key_cache, - ) - monkeypatch.setattr( - "litellm.proxy.proxy_server.proxy_logging_obj", MagicMock() - ) - - # Build a mock request that simulates: - # POST /key/block - # Authorization: Bearer - # Body: {"key": "sk-does-not-exist"} - mock_request = MagicMock() - mock_request.headers = { - "Authorization": f"Bearer {master_key}", - "Content-Type": "application/json", - } - mock_request.url.path = "/key/block" - mock_request.method = "POST" - mock_request.query_params = {} - mock_request.scope = { - "type": "http", - "path": "/key/block", - # Simulate a pre-parsed body with a 'key' field that differs from the master key - "parsed_body": ( - ("key",), - {"key": "sk-does-not-exist"}, - ), - } - mock_request.state = MagicMock() - - # Call user_api_key_auth the way FastAPI does. If the body 'key' field - # contaminates the api_key parameter, pass it explicitly to prove the - # guard works. - result = await user_api_key_auth( - request=mock_request, - api_key="sk-does-not-exist", # Simulates the contaminated value - ) - - # Auth should succeed — the guard re-reads the Authorization header - assert result is not None - assert result.user_role == LitellmUserRoles.PROXY_ADMIN From cd549bf4f5d61d3eaf0195371a5d2a5dcf98df0a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 18 Mar 2026 00:40:00 -0700 Subject: [PATCH 09/18] [Refactor] UI - Playground: Extract FilePreviewCard from ChatUI Extract duplicate file preview JSX blocks (responses and chat image previews) into a reusable FilePreviewCard component, reducing ~50 lines of duplicated markup in ChatUI.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/playground/chat_ui/ChatUI.tsx | 69 +++--------------- .../chat_ui/FilePreviewCard.test.tsx | 70 +++++++++++++++++++ .../playground/chat_ui/FilePreviewCard.tsx | 45 ++++++++++++ 3 files changed, 126 insertions(+), 58 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/playground/chat_ui/FilePreviewCard.test.tsx create mode 100644 ui/litellm-dashboard/src/components/playground/chat_ui/FilePreviewCard.tsx diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx index eba9d37669..bc0d52cf58 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -60,6 +60,7 @@ import CodeInterpreterOutput from "./CodeInterpreterOutput"; import CodeInterpreterTool from "./CodeInterpreterTool"; import { generateCodeSnippet } from "./CodeSnippets"; import EndpointSelector from "./EndpointSelector"; +import FilePreviewCard from "./FilePreviewCard"; import MCPEventsDisplay from "./MCPEventsDisplay"; import type { MCPEvent } from "../../mcp_tools/types"; import { EndpointType, getEndpointType } from "./mode_endpoint_mapping"; @@ -2231,67 +2232,19 @@ const ChatUI: React.FC = ({ {/* Show file previews above input when files are uploaded */} {endpointType === EndpointType.RESPONSES && responsesUploadedImage && ( -
-
-
- {responsesUploadedImage.name.toLowerCase().endsWith(".pdf") ? ( -
- -
- ) : ( - Upload preview - )} -
-
-
{responsesUploadedImage.name}
-
- {responsesUploadedImage.name.toLowerCase().endsWith(".pdf") ? "PDF" : "Image"} -
-
- -
-
+ )} {endpointType === EndpointType.CHAT && chatUploadedImage && ( -
-
-
- {chatUploadedImage.name.toLowerCase().endsWith(".pdf") ? ( -
- -
- ) : ( - Upload preview - )} -
-
-
{chatUploadedImage.name}
-
- {chatUploadedImage.name.toLowerCase().endsWith(".pdf") ? "PDF" : "Image"} -
-
- -
-
+ )} {/* Code Interpreter indicator and sample prompts when enabled */} diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/FilePreviewCard.test.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/FilePreviewCard.test.tsx new file mode 100644 index 0000000000..1014df5d16 --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/FilePreviewCard.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import FilePreviewCard from "./FilePreviewCard"; + +function makeFile(name: string): File { + return new File(["dummy"], name, { type: "application/octet-stream" }); +} + +describe("FilePreviewCard", () => { + it("should render", () => { + render( + + ); + expect(screen.getByText("photo.png")).toBeInTheDocument(); + }); + + it("should display the file name", () => { + render( + + ); + expect(screen.getByText("my-screenshot.jpg")).toBeInTheDocument(); + }); + + it("should show 'Image' label for non-PDF files", () => { + render( + + ); + expect(screen.getByText("Image")).toBeInTheDocument(); + }); + + it("should show 'PDF' label for PDF files", () => { + render( + + ); + expect(screen.getByText("PDF")).toBeInTheDocument(); + }); + + it("should render an image preview when the file is not a PDF", () => { + render( + + ); + expect(screen.getByAltText("Upload preview")).toBeInTheDocument(); + }); + + it("should not render an image preview when the file is a PDF", () => { + render( + + ); + expect(screen.queryByAltText("Upload preview")).not.toBeInTheDocument(); + }); + + it("should call onRemove when the remove button is clicked", async () => { + const onRemove = vi.fn(); + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole("button")); + expect(onRemove).toHaveBeenCalledOnce(); + }); + + it("should treat .PDF (uppercase) as a PDF file", () => { + render( + + ); + expect(screen.getByText("PDF")).toBeInTheDocument(); + expect(screen.queryByAltText("Upload preview")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/FilePreviewCard.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/FilePreviewCard.tsx new file mode 100644 index 0000000000..26bdf07eda --- /dev/null +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/FilePreviewCard.tsx @@ -0,0 +1,45 @@ +import { DeleteOutlined, FilePdfOutlined } from "@ant-design/icons"; + +interface FilePreviewCardProps { + file: File; + previewUrl: string | null; + onRemove: () => void; +} + +function FilePreviewCard({ file, previewUrl, onRemove }: FilePreviewCardProps) { + const isPdf = file.name.toLowerCase().endsWith(".pdf"); + + return ( +
+
+
+ {isPdf ? ( +
+ +
+ ) : ( + Upload preview + )} +
+
+
{file.name}
+
+ {isPdf ? "PDF" : "Image"} +
+
+ +
+
+ ); +} + +export default FilePreviewCard; From dcc0c709365eca246ea16d7ded577728af2c6d52 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 18 Mar 2026 11:59:31 -0700 Subject: [PATCH 10/18] [Refactor] Extract _convert_teams_to_response_models to fix PLR0915 list_team_v2 had 51 statements (limit 50). Extract the team-to-response-model conversion loop into a helper function to satisfy ruff PLR0915. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../management_endpoints/team_endpoints.py | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index e7cd6c1748..bee4b2517e 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3323,6 +3323,30 @@ async def _build_team_list_where_conditions( return where_conditions +def _convert_teams_to_response_models( + teams: list, + use_deleted_table: bool, +) -> List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]: + """Convert raw Prisma team rows to response models.""" + team_list: List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] = [] + for team in teams: + try: + team_dict = team.model_dump() + except Exception: + team_dict = team.dict() + + if use_deleted_table: + team_list.append(LiteLLM_DeletedTeamTable(**team_dict)) + else: + members_with_roles = team_dict.get("members_with_roles") + if not isinstance(members_with_roles, list): + members_with_roles = [] + team_dict["members_with_roles"] = members_with_roles + members_count = len(members_with_roles) + team_list.append(TeamListItem(**team_dict, members_count=members_count)) + return team_list + + @router.get( "/v2/team/list", tags=["team management"], @@ -3521,22 +3545,7 @@ async def list_team_v2( total_pages = -(-total_count // page_size) # Ceiling division # Convert Prisma models to response models with members_count - team_list: List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] = [] - for team in teams: - try: - team_dict = team.model_dump() - except Exception: - team_dict = team.dict() - - if use_deleted_table: - team_list.append(LiteLLM_DeletedTeamTable(**team_dict)) - else: - members_with_roles = team_dict.get("members_with_roles") - if not isinstance(members_with_roles, list): - members_with_roles = [] - team_dict["members_with_roles"] = members_with_roles - members_count = len(members_with_roles) - team_list.append(TeamListItem(**team_dict, members_count=members_count)) + team_list = _convert_teams_to_response_models(teams, use_deleted_table) return { "teams": team_list, From bb628e974a122a8d89ecad2123bb22a4e95173fb Mon Sep 17 00:00:00 2001 From: jyeros Date: Wed, 18 Mar 2026 15:01:14 -0500 Subject: [PATCH 11/18] Add otel ignore_context_propagation as opt in --- litellm/integrations/opentelemetry.py | 21 +++++++------------ .../integrations/test_opentelemetry.py | 2 +- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 7689a6cc7e..559ed05d30 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -11,7 +11,7 @@ from litellm.integrations._types.open_inference import ( ) from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.safe_json_dumps import safe_dumps -from litellm.secret_managers.main import get_secret_bool +from litellm.secret_managers.main import get_secret_bool, str_to_bool from litellm.types.services import ServiceLoggerPayload from litellm.types.utils import ( ChatCompletionMessageToolCall, @@ -68,6 +68,7 @@ class OpenTelemetryConfig: service_name: Optional[str] = None deployment_environment: Optional[str] = None model_id: Optional[str] = None + ignore_context_propagation: Optional[bool] = None def __post_init__(self) -> None: # If endpoint is specified but exporter is still the default "console", @@ -89,6 +90,10 @@ class OpenTelemetryConfig: ) if not self.model_id: self.model_id = os.getenv("OTEL_MODEL_ID", self.service_name) + if self.ignore_context_propagation is None: + self.ignore_context_propagation = str_to_bool( + os.getenv("OTEL_IGNORE_CONTEXT_PROPAGATION") + ) @classmethod def from_env(cls): @@ -710,12 +715,7 @@ class OpenTelemetry(CustomLogger): ) ctx, parent_span = self._get_span_context(kwargs) - # CRITICAL FIX: For langfuse_otel, ALWAYS create primary spans - # Don't use parent spans from other providers as they cause trace corruption - is_langfuse_otel = ( - hasattr(self, "callback_name") and self.callback_name == "langfuse_otel" - ) - if is_langfuse_otel: + if self.config.ignore_context_propagation: parent_span = None # Ignore parent spans from other providers ctx = None @@ -1256,12 +1256,7 @@ class OpenTelemetry(CustomLogger): ) _parent_context, parent_otel_span = self._get_span_context(kwargs) - # CRITICAL FIX: For langfuse_otel, ALWAYS create primary spans - # Don't use parent spans from other providers as they cause trace corruption - is_langfuse_otel = ( - hasattr(self, "callback_name") and self.callback_name == "langfuse_otel" - ) - if is_langfuse_otel: + if self.config.ignore_context_propagation: parent_otel_span = None # Ignore parent spans from other providers _parent_context = None diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 7f3b44e142..3d6fc146a0 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -2175,7 +2175,7 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter)) otel = OpenTelemetry( - callback_name="langfuse_otel", + config=OpenTelemetryConfig(ignore_context_propagation=True), tracer_provider=tracer_provider, ) otel.tracer = tracer_provider.get_tracer("litellm") From 2b6a32e0cd966f0b758eea416e88a6f757b0a966 Mon Sep 17 00:00:00 2001 From: jyeros Date: Wed, 18 Mar 2026 15:02:03 -0500 Subject: [PATCH 12/18] Add tests --- poetry.lock | 88 +++++++---- pyproject.toml | 1 + .../integrations/test_opentelemetry.py | 141 ++++++++++++++---- 3 files changed, 167 insertions(+), 63 deletions(-) diff --git a/poetry.lock b/poetry.lock index 591d0c270e..86d5b1e4c9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "a2a-sdk" @@ -7,11 +7,11 @@ description = "A2A Python SDK" optional = false python-versions = ">=3.10" groups = ["main", "proxy-dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "a2a_sdk-0.3.22-py3-none-any.whl", hash = "sha256:b98701135bb90b0ff85d35f31533b6b7a299bf810658c1c65f3814a6c15ea385"}, {file = "a2a_sdk-0.3.22.tar.gz", hash = "sha256:77a5694bfc4f26679c11b70c7f1062522206d430b34bc1215cfbb1eba67b7e7d"}, ] +markers = {main = "python_version >= \"3.10\" and extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [package.dependencies] google-api-core = ">=1.26.0" @@ -385,6 +385,7 @@ files = [ {file = "azure_core-1.36.0-py3-none-any.whl", hash = "sha256:fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b"}, {file = "azure_core-1.36.0.tar.gz", hash = "sha256:22e5605e6d0bf1d229726af56d9e92bc37b6e726b141a18be0b4d424131741b7"}, ] +markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""} [package.dependencies] requests = ">=2.21.0" @@ -405,6 +406,7 @@ files = [ {file = "azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651"}, {file = "azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456"}, ] +markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""} [package.dependencies] azure-core = ">=1.31.0" @@ -598,7 +600,7 @@ files = [ {file = "cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace"}, {file = "cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [[package]] name = "certifi" @@ -705,7 +707,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] -markers = {main = "platform_python_implementation != \"PyPy\" or extra == \"proxy\"", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""} +markers = {main = "(platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -1055,6 +1057,7 @@ files = [ {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, ] +markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\") or extra == \"proxy\" or extra == \"extra-proxy\""} [package.dependencies] cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} @@ -1837,11 +1840,11 @@ description = "Google API client core library" optional = false python-versions = ">=3.7" groups = ["main", "proxy-dev"] -markers = "python_version >= \"3.14\"" files = [ {file = "google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7"}, {file = "google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300"}, ] +markers = {main = "python_version >= \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.14\""} [package.dependencies] google-auth = ">=2.14.1,<3.0.0" @@ -1869,7 +1872,7 @@ files = [ {file = "google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c"}, {file = "google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8"}, ] -markers = {main = "(python_version >= \"3.10\" or extra == \"google\" or extra == \"extra-proxy\") and python_version < \"3.14\"", proxy-dev = "python_version >= \"3.10\" and python_version < \"3.14\""} +markers = {main = "python_version < \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.10\" and python_version < \"3.14\""} [package.dependencies] google-auth = ">=2.14.1,<3.0.0" @@ -1906,7 +1909,7 @@ files = [ {file = "google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16"}, {file = "google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [package.dependencies] cachetools = ">=2.0.0,<7.0" @@ -2078,11 +2081,11 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" -grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" -proto-plus = ">=1.22.3,<2.0.0dev" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0.dev0" +proto-plus = ">=1.22.3,<2.0.0.dev0" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" [[package]] name = "google-cloud-resource-manager" @@ -2264,7 +2267,7 @@ files = [ {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\") or extra == \"google\" or extra == \"extra-proxy\""} [package.dependencies] grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} @@ -2673,11 +2676,11 @@ description = "Consume Server-Sent Event (SSE) messages with HTTPX." optional = false python-versions = ">=3.9" groups = ["main", "proxy-dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"}, {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, ] +markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\")", proxy-dev = "python_version >= \"3.10\""} [[package]] name = "huey" @@ -3042,7 +3045,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -3219,15 +3222,15 @@ files = [ [[package]] name = "litellm-proxy-extras" -version = "0.4.56" +version = "0.4.57" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." optional = true python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" groups = ["main"] markers = "extra == \"proxy\"" files = [ - {file = "litellm_proxy_extras-0.4.56-py3-none-any.whl", hash = "sha256:52dbe3b5358c790e77e12f1ec5ef8e7508b383c2aaf41299750b6fb400908ee7"}, - {file = "litellm_proxy_extras-0.4.56.tar.gz", hash = "sha256:63ad59baa0defccc5c929cfd933ee7e32a6614b0fc5fa0fc45a12d7608e33f08"}, + {file = "litellm_proxy_extras-0.4.57-py3-none-any.whl", hash = "sha256:04538223cd80318a72d70c6e10f701598e58c763368296a6503c674c92fbdb62"}, + {file = "litellm_proxy_extras-0.4.57.tar.gz", hash = "sha256:ef9b95dc42237614216833bd5d46ebf9dea1caa5ea14ea1a66d7f7842b224ec2"}, ] [[package]] @@ -3713,6 +3716,7 @@ files = [ {file = "msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1"}, {file = "msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f"}, ] +markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""} [package.dependencies] cryptography = ">=2.5,<49" @@ -3733,6 +3737,7 @@ files = [ {file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"}, {file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"}, ] +markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""} [package.dependencies] msal = ">=1.29,<2" @@ -3983,6 +3988,7 @@ files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +markers = {main = "extra == \"extra-proxy\""} [[package]] name = "numpy" @@ -4105,7 +4111,7 @@ files = [ {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"}, {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"}, ] -markers = {main = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""} [package.dependencies] importlib-metadata = ">=6.0,<8.8.0" @@ -4220,7 +4226,7 @@ files = [ {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"}, {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"}, ] -markers = {main = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""} [package.dependencies] opentelemetry-api = "1.39.1" @@ -4238,7 +4244,7 @@ files = [ {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"}, {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"}, ] -markers = {main = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""} [package.dependencies] opentelemetry-api = "1.39.1" @@ -4455,6 +4461,21 @@ sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-d test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] xml = ["lxml (>=4.9.2)"] +[[package]] +name = "parameterized" +version = "0.9.0" +description = "Parameterized testing with any Python test framework" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, + {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, +] + +[package.extras] +dev = ["jinja2"] + [[package]] name = "pathspec" version = "0.12.1" @@ -4722,6 +4743,7 @@ files = [ {file = "prisma-0.11.0-py3-none-any.whl", hash = "sha256:22bb869e59a2968b99f3483bb417717273ffbc569fd1e9ceed95e5614cbaf53a"}, {file = "prisma-0.11.0.tar.gz", hash = "sha256:3f2f2fd2361e1ec5ff655f2a04c7860c2f2a5bc4c91f78ca9c5c6349735bf693"}, ] +markers = {main = "extra == \"extra-proxy\""} [package.dependencies] click = ">=7.1.2" @@ -4895,7 +4917,7 @@ files = [ {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [package.dependencies] protobuf = ">=3.19.0,<7.0.0" @@ -4923,7 +4945,7 @@ files = [ {file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"}, {file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\""} [[package]] name = "psutil" @@ -5083,7 +5105,7 @@ files = [ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [[package]] name = "pyasn1-modules" @@ -5096,7 +5118,7 @@ files = [ {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [package.dependencies] pyasn1 = ">=0.6.1,<0.7.0" @@ -5124,7 +5146,7 @@ files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] -markers = {main = "implementation_name != \"PyPy\" and (platform_python_implementation != \"PyPy\" or extra == \"proxy\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""} +markers = {main = "implementation_name != \"PyPy\" and (platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""} [[package]] name = "pydantic" @@ -5347,6 +5369,7 @@ files = [ {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, ] +markers = {main = "(python_version <= \"3.13\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"extra-proxy\" or extra == \"proxy\")"} [package.dependencies] cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} @@ -6290,7 +6313,7 @@ files = [ {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, ] -markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""} +markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""} [package.dependencies] pyasn1 = ">=0.1.3" @@ -6336,10 +6359,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "scikit-learn" @@ -6492,9 +6515,9 @@ tornado = ">=6.4.2,<7" urllib3 = ">=1.26,<3" [package.extras] -all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.00)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"] +all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.0)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"] bedrock = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)"] -cohere = ["cohere (>=5.9.4,<6.00)"] +cohere = ["cohere (>=5.9.4,<6.0)"] dev = ["dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "ipykernel (>=6.25.0,<7)", "mypy (>=1.7.1,<2)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"] docs = ["pydoc-markdown (>=4.8.2) ; python_version < \"3.12\""] fastembed = ["fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\""] @@ -7222,6 +7245,7 @@ files = [ {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, ] +markers = {main = "extra == \"extra-proxy\""} [[package]] name = "tornado" @@ -7994,4 +8018,4 @@ utils = ["numpydoc"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0" -content-hash = "1f3bbf967451633fb6290ba88980bdf4fbf83420024b14e862d1da717d903684" +content-hash = "0002021a7733b370a9855b26198e8e6dc49a62b67f1eface6f2fbe406ff3c3ac" diff --git a/pyproject.toml b/pyproject.toml index 0e2fb40d93..55a825d831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -167,6 +167,7 @@ langfuse = "^2.45.0" fastapi-offline = "^1.7.3" fakeredis = "^2.27.1" pytest-rerunfailures = "^14.0" +parameterized = "^0.9.0" [tool.poetry.group.proxy-dev.dependencies] prisma = "0.11.0" diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 3d6fc146a0..4e2d4d1ac7 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -3,7 +3,8 @@ import os import sys import time import unittest -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from parameterized import parameterized from unittest.mock import MagicMock, patch # Adds the grandparent directory to sys.path to allow importing project modules @@ -420,11 +421,12 @@ class TestOpenTelemetry(unittest.TestCase): otel.tracer = MagicMock() # Mock the dynamic header extraction and tracer creation - with patch.object( - otel, "_get_dynamic_otel_headers_from_kwargs" - ) as mock_get_headers, patch.object( - otel, "_get_tracer_with_dynamic_headers" - ) as mock_get_tracer: + with ( + patch.object( + otel, "_get_dynamic_otel_headers_from_kwargs" + ) as mock_get_headers, + patch.object(otel, "_get_tracer_with_dynamic_headers") as mock_get_tracer, + ): # Test case 1: With dynamic headers mock_get_headers.return_value = { "arize-space-id": "test-space", @@ -2165,10 +2167,15 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): mock_span.set_attribute.assert_any_call("gen_ai.operation.name", "chat") - def test_handle_failure_langfuse_otel_nulls_parent_span(self): + @parameterized.expand([("_handle_success",), ("_handle_failure",)]) + def test_handle_success_failure_nulls_parent_span_if_ignore_context_propagation( + self, handle_method: str + ): """ - For langfuse_otel, _handle_failure should ignore parent spans from other providers - and create a root-level error span (symmetric with _handle_success). + If ignore_context_propagation is True, _handle_success should ignore any parent span + and create a root-level error span. This could be useful for langfuse_otel where + _handle_success may ignore parent spans from other providers and create a root-level + error span (symmetric with _handle_failure). """ span_exporter = InMemorySpanExporter() tracer_provider = TracerProvider() @@ -2181,9 +2188,9 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): otel.tracer = tracer_provider.get_tracer("litellm") other_tracer = tracer_provider.get_tracer("other_provider") - other_span = other_tracer.start_span("other_provider_span") + other_span = other_tracer.start_span("parent_span") - start = datetime.utcnow() + start = datetime.now(timezone.utc) end = start + timedelta(seconds=1) kwargs = { @@ -2202,23 +2209,33 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): "exception": Exception("test error"), } - otel._handle_failure(kwargs, None, start, end) + with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}): + match handle_method: + case "_handle_success": + otel._handle_success(kwargs, None, start, end) + case "_handle_failure": + otel._handle_failure(kwargs, None, start, end) + case _: + self.fail(f"Invalid handle_method: {handle_method}") other_span.end() spans = span_exporter.get_finished_spans() - failure_spans = [s for s in spans if s.name != "other_provider_span"] + child_spans = [s for s in spans if s.name != "parent_span"] + child_span_ids = {s.context.span_id for s in child_spans if s.context} - self.assertTrue(failure_spans, "Expected at least one failure span") - for span in failure_spans: - self.assertIsNone( - span.parent, - f"langfuse_otel failure span should be a root span, but has parent: {span.parent}", - ) + self.assertTrue(child_spans, "Expected at least one child span") + for span in child_spans: + assert ( + span.parent is None or span.parent.span_id in child_span_ids + ), f"if ignore_context_propagation is True, span should not have parent from other providers, but got parent: {span.parent}" - def test_handle_failure_non_langfuse_preserves_parent_span(self): + @parameterized.expand([("_handle_success",), ("_handle_failure",)]) + def test_handle_success_failure_default_preserves_parent_span( + self, handle_method: str + ): """ - For non-langfuse_otel callbacks, _handle_failure should still use parent spans normally. + For default otel callbacks, _handle_success should use parent spans normally. """ span_exporter = InMemorySpanExporter() tracer_provider = TracerProvider() @@ -2229,7 +2246,7 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): parent_span = otel.tracer.start_span("parent_span") - start = datetime.utcnow() + start = datetime.now(timezone.utc) end = start + timedelta(seconds=1) kwargs = { @@ -2249,19 +2266,83 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): } with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}): - otel._handle_failure(kwargs, None, start, end) + match handle_method: + case "_handle_success": + otel._handle_success(kwargs, None, start, end) + case "_handle_failure": + otel._handle_failure(kwargs, None, start, end) + case _: + self.fail(f"Invalid handle_method: {handle_method}") parent_span.end() spans = span_exporter.get_finished_spans() child_spans = [s for s in spans if s.name != "parent_span"] - self.assertTrue(child_spans, "Expected at least one child failure span") + self.assertTrue(child_spans, "Expected at least one child span") for span in child_spans: - self.assertIsNotNone( - span.parent, - "Non-langfuse_otel failure span should have a parent", - ) + assert ( + span.parent is not None + ), f"By default parent span should be preserved, but got None parent for span: {span.name}" + + @parameterized.expand([("_handle_success",), ("_handle_failure",)]) + def test_handle_success_failure_with_context_propagation_preserves_parent_span( + self, handle_method: str + ): + """ + For otel callbacks with context propagation enabled, _handle__handle_success should + use parent spans normally. + """ + span_exporter = InMemorySpanExporter() + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + + otel = OpenTelemetry( + config=OpenTelemetryConfig(ignore_context_propagation=False), + tracer_provider=tracer_provider, + ) + otel.tracer = tracer_provider.get_tracer("litellm") + + parent_span = otel.tracer.start_span("parent_span") + + start = datetime.now(timezone.utc) + end = start + timedelta(seconds=1) + + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": { + "custom_llm_provider": "openai", + "metadata": {"litellm_parent_otel_span": parent_span}, + }, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + }, + "exception": Exception("test error"), + } + + with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}): + match handle_method: + case "_handle_success": + otel._handle_success(kwargs, None, start, end) + case "_handle_failure": + otel._handle_failure(kwargs, None, start, end) + case _: + self.fail(f"Invalid handle_method: {handle_method}") + + parent_span.end() + + spans = span_exporter.get_finished_spans() + child_spans = [s for s in spans if s.name != "parent_span"] + + self.assertTrue(child_spans, "Expected at least one child span") + for span in child_spans: + assert ( + span.parent is not None + ), f"If ignore_context_propagation is False, parent span should be preserved, but got None parent for span: {span.name}" def test_handle_failure_hasattr_guard_on_parent_name(self): """ @@ -2431,9 +2512,7 @@ class TestNoParentSpanDuplication(unittest.TestCase): otel._handle_success(kwargs, response_obj, start, end) spans = span_exporter.get_finished_spans() - proxy_spans = [ - s for s in spans if s.name == LITELLM_PROXY_REQUEST_SPAN_NAME - ] + proxy_spans = [s for s in spans if s.name == LITELLM_PROXY_REQUEST_SPAN_NAME] self.assertEqual(len(proxy_spans), 1, "Should have exactly one proxy span") proxy_attrs = proxy_spans[0].attributes or {} From 7fac4d48ac9461de0271855467e0d9fd0a8e5617 Mon Sep 17 00:00:00 2001 From: jyeros Date: Wed, 18 Mar 2026 15:47:50 -0500 Subject: [PATCH 13/18] Update docs --- .../docs/observability/langfuse_otel_integration.md | 6 ++++++ docs/my-website/docs/proxy/config_settings.md | 1 + 2 files changed, 7 insertions(+) diff --git a/docs/my-website/docs/observability/langfuse_otel_integration.md b/docs/my-website/docs/observability/langfuse_otel_integration.md index b4c9a2bd1a..79ad2f6f75 100644 --- a/docs/my-website/docs/observability/langfuse_otel_integration.md +++ b/docs/my-website/docs/observability/langfuse_otel_integration.md @@ -83,6 +83,9 @@ os.environ["LANGFUSE_OTEL_HOST"] = "https://cloud.langfuse.com" # EU region # Or use self-hosted instance # os.environ["LANGFUSE_OTEL_HOST"] = "https://my-langfuse.company.com" +# Optional: Ignore otel context propagation to prevent parent-child relationships with spans from other providers +# os.environ["OTEL_IGNORE_CONTEXT_PROPAGATION"] = "true" + litellm.callbacks = ["langfuse_otel"] ``` @@ -124,6 +127,9 @@ export LANGFUSE_PUBLIC_KEY="pk-lf-..." export LANGFUSE_SECRET_KEY="sk-lf-..." export LANGFUSE_OTEL_HOST="https://us.cloud.langfuse.com" # Default US region # export LANGFUSE_OTEL_HOST="https://otel.my-langfuse.company.com" # custom OTEL endpoint + +# Optional: Ignore otel context propagation to prevent parent-child relationships with spans from other providers +# export OTEL_IGNORE_CONTEXT_PROPAGATION="true" ``` 2. Setup config.yaml diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index f5b611a85a..46f557d042 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -100,6 +100,7 @@ litellm_settings: callback_settings: otel: message_logging: boolean # OTEL logging callback specific settings + ignore_context_propagation: boolean # if true, spans created by the OTEL callback will not have parent spans from other providers, even if OTEL context propagation is enabled. This can help prevent issues with incorrect parent-child relationships in spans when multiple providers are used. general_settings: completion_model: string From f88e51e1b925f82909d805f0b1a97c8734c50de9 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 18 Mar 2026 14:07:22 -0700 Subject: [PATCH 14/18] =?UTF-8?q?bump:=20version=200.4.57=20=E2=86=92=200.?= =?UTF-8?q?4.58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- litellm-proxy-extras/pyproject.toml | 4 ++-- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 006aad9480..8aa9e70e43 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm-proxy-extras" -version = "0.4.57" +version = "0.4.58" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." authors = ["BerriAI"] readme = "README.md" @@ -22,7 +22,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "0.4.57" +version = "0.4.58" version_files = [ "pyproject.toml:version", "../requirements.txt:litellm-proxy-extras==", diff --git a/pyproject.toml b/pyproject.toml index 0e2fb40d93..15fa1e3bb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ boto3 = { version = "^1.40.76", optional = true } redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"} mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"} a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"} -litellm-proxy-extras = {version = "^0.4.57", optional = true} +litellm-proxy-extras = {version = "^0.4.58", optional = true} rich = {version = "^13.7.1", optional = true} litellm-enterprise = {version = "^0.1.33", optional = true} diskcache = {version = "^5.6.1", optional = true} diff --git a/requirements.txt b/requirements.txt index 827986487f..d420f4ac60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ grpcio>=1.75.0; python_version >= "3.14" sentry_sdk==2.21.0 # for sentry error handling detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests tzdata==2025.1 # IANA time zone database -litellm-proxy-extras==0.4.57 # for proxy extras - e.g. prisma migrations +litellm-proxy-extras==0.4.58 # for proxy extras - e.g. prisma migrations llm-sandbox==0.3.31 # for skill execution in sandbox ### LITELLM PACKAGE DEPENDENCIES python-dotenv==1.0.1 # for env From 7aa673a1375f9054a9df3c4ceb4675c5ecc72b54 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 18 Mar 2026 14:07:43 -0700 Subject: [PATCH 15/18] adding build --- ...litellm_proxy_extras-0.4.58-py3-none-any.whl | Bin 0 -> 76589 bytes .../dist/litellm_proxy_extras-0.4.58.tar.gz | Bin 0 -> 32038 bytes .../migration.sql | 9 +++++++++ 3 files changed, 9 insertions(+) create mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.58-py3-none-any.whl create mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.58.tar.gz create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260318140652_add_index_to_team_table/migration.sql diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.58-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.58-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..756080f85444bd262788ecc555780fd4d4264a11 GIT binary patch literal 76589 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 literal 0 HcmV?d00001 diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.58.tar.gz b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.58.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..8a52b0584056ce8886164d679617f3d60f298c83 GIT binary patch literal 32038 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 Date: Wed, 18 Mar 2026 14:09:46 -0700 Subject: [PATCH 16/18] updating poetry lock --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 591d0c270e..2c8a125a6e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3219,15 +3219,15 @@ files = [ [[package]] name = "litellm-proxy-extras" -version = "0.4.56" +version = "0.4.58" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." optional = true python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" groups = ["main"] markers = "extra == \"proxy\"" files = [ - {file = "litellm_proxy_extras-0.4.56-py3-none-any.whl", hash = "sha256:52dbe3b5358c790e77e12f1ec5ef8e7508b383c2aaf41299750b6fb400908ee7"}, - {file = "litellm_proxy_extras-0.4.56.tar.gz", hash = "sha256:63ad59baa0defccc5c929cfd933ee7e32a6614b0fc5fa0fc45a12d7608e33f08"}, + {file = "litellm_proxy_extras-0.4.58-py3-none-any.whl", hash = "sha256:8863e70de833c0e35119a1cbbf583619bdebe52222efd5654586519175ba403b"}, + {file = "litellm_proxy_extras-0.4.58.tar.gz", hash = "sha256:84a67483329eced8be4fc61c4e43f117287aa4e3deeb8ddf8fe8cdc9a8508836"}, ] [[package]] @@ -7994,4 +7994,4 @@ utils = ["numpydoc"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0" -content-hash = "1f3bbf967451633fb6290ba88980bdf4fbf83420024b14e862d1da717d903684" +content-hash = "eda34dfd8b35474beffee18893d6782c7b3d0d3d2c610f66237eb97176f43527" From 02ebd1302e088a531cd1aa6579f1c4d2349b26ca Mon Sep 17 00:00:00 2001 From: jyeros Date: Wed, 18 Mar 2026 16:12:37 -0500 Subject: [PATCH 17/18] Code review --- docs/my-website/docs/proxy/config_settings.md | 1 - .../integrations/test_opentelemetry.py | 60 +++++++++---------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 46f557d042..f5b611a85a 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -100,7 +100,6 @@ litellm_settings: callback_settings: otel: message_logging: boolean # OTEL logging callback specific settings - ignore_context_propagation: boolean # if true, spans created by the OTEL callback will not have parent spans from other providers, even if OTEL context propagation is enabled. This can help prevent issues with incorrect parent-child relationships in spans when multiple providers are used. general_settings: completion_model: string diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 4e2d4d1ac7..450f4ab83e 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -421,12 +421,12 @@ class TestOpenTelemetry(unittest.TestCase): otel.tracer = MagicMock() # Mock the dynamic header extraction and tracer creation - with ( - patch.object( - otel, "_get_dynamic_otel_headers_from_kwargs" - ) as mock_get_headers, - patch.object(otel, "_get_tracer_with_dynamic_headers") as mock_get_tracer, - ): + with patch.object( + otel, "_get_dynamic_otel_headers_from_kwargs" + ) as mock_get_headers, patch.object( + otel, "_get_tracer_with_dynamic_headers" + ) as mock_get_tracer: + # Test case 1: With dynamic headers mock_get_headers.return_value = { "arize-space-id": "test-space", @@ -2173,9 +2173,9 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): ): """ If ignore_context_propagation is True, _handle_success should ignore any parent span - and create a root-level error span. This could be useful for langfuse_otel where + and create a root-level span. This could be useful for langfuse_otel where _handle_success may ignore parent spans from other providers and create a root-level - error span (symmetric with _handle_failure). + span (symmetric with _handle_failure). """ span_exporter = InMemorySpanExporter() tracer_provider = TracerProvider() @@ -2210,13 +2210,12 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): } with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}): - match handle_method: - case "_handle_success": - otel._handle_success(kwargs, None, start, end) - case "_handle_failure": - otel._handle_failure(kwargs, None, start, end) - case _: - self.fail(f"Invalid handle_method: {handle_method}") + if handle_method == "_handle_success": + otel._handle_success(kwargs, None, start, end) + elif handle_method == "_handle_failure": + otel._handle_failure(kwargs, None, start, end) + else: + self.fail(f"Invalid handle_method: {handle_method}") other_span.end() @@ -2236,6 +2235,7 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): ): """ For default otel callbacks, _handle_success should use parent spans normally. + (symmetric with _handle_failure) """ span_exporter = InMemorySpanExporter() tracer_provider = TracerProvider() @@ -2266,13 +2266,12 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): } with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}): - match handle_method: - case "_handle_success": - otel._handle_success(kwargs, None, start, end) - case "_handle_failure": - otel._handle_failure(kwargs, None, start, end) - case _: - self.fail(f"Invalid handle_method: {handle_method}") + if handle_method == "_handle_success": + otel._handle_success(kwargs, None, start, end) + elif handle_method == "_handle_failure": + otel._handle_failure(kwargs, None, start, end) + else: + self.fail(f"Invalid handle_method: {handle_method}") parent_span.end() @@ -2290,8 +2289,8 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): self, handle_method: str ): """ - For otel callbacks with context propagation enabled, _handle__handle_success should - use parent spans normally. + For otel callbacks with context propagation enabled, _handle_success should + use parent spans normally. (symmetric with _handle_failure) """ span_exporter = InMemorySpanExporter() tracer_provider = TracerProvider() @@ -2325,13 +2324,12 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): } with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}): - match handle_method: - case "_handle_success": - otel._handle_success(kwargs, None, start, end) - case "_handle_failure": - otel._handle_failure(kwargs, None, start, end) - case _: - self.fail(f"Invalid handle_method: {handle_method}") + if handle_method == "_handle_success": + otel._handle_success(kwargs, None, start, end) + elif handle_method == "_handle_failure": + otel._handle_failure(kwargs, None, start, end) + else: + self.fail(f"Invalid handle_method: {handle_method}") parent_span.end() From 8e61b32b8e76b3318cd74532fa4dcf1af51803c4 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 18 Mar 2026 15:09:01 -0700 Subject: [PATCH 18/18] [Staging] - Ishaan March 17th (#23903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(xai): add grok-4.20 beta 2 models with pricing (#23900) Add three grok-4.20 beta 2 model variants from xAI: - grok-4.20-multi-agent-beta-0309 (reasoning + multi-agent) - grok-4.20-beta-0309-reasoning (reasoning) - grok-4.20-beta-0309-non-reasoning Pricing (from https://docs.x.ai/docs/models): - Input: $2.00/1M tokens ($0.20/1M cached) - Output: $6.00/1M tokens - Context: 2M tokens All variants support vision, function calling, tool choice, and web search. Closes LIT-2171 * docs: add Quick Install section for litellm --setup wizard (#23905) * docs: add Quick Install section for litellm --setup wizard * docs: clarify setup wizard is for local/beginner use * feat(setup): interactive setup wizard + install.sh (#23644) * feat(setup): add interactive setup wizard + install.sh Adds `litellm --setup` — a Claude Code-style TUI onboarding wizard that guides users through provider selection, API key entry, and proxy config generation, then optionally starts the proxy immediately. - litellm/setup_wizard.py: wizard with ASCII art, numbered provider menu (OpenAI, Anthropic, Azure, Gemini, Bedrock, Ollama), API key prompts, port/master-key config, and litellm_config.yaml generation - litellm/proxy/proxy_cli.py: adds --setup flag that invokes the wizard - scripts/install.sh: curl-installable script (detect OS/Python, pip install litellm[proxy], launch wizard) Usage: curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/install.sh | sh litellm --setup * fix(install.sh): remove orange color, add LITELLM_BRANCH env var for branch installs * fix(install.sh): install from git branch so --setup is available for QA * fix(install.sh): remove stale LITELLM_BRANCH reference that caused unbound variable error * fix(install.sh): force-reinstall from git to bypass cached PyPI version * fix(install.sh): show pip progress bar during install * fix(install.sh): always launch wizard via $PYTHON_BIN -m litellm, not PATH binary * fix(install.sh): use litellm.proxy.proxy_cli module (no __main__.py exists) * fix(install.sh): suppress RuntimeWarning from module invocation * fix(install.sh): use Python bin-dir litellm binary to avoid CWD sys.path shadowing * fix(install.sh): use sysconfig.get_path('scripts') to find pip-installed litellm binary * fix(install.sh): redirect stdin from /dev/tty on exec so wizard gets terminal, not exhausted pipe * fix(install.sh): warn about git clone duration, drop --no-cache-dir so re-runs are faster * feat(setup_wizard): arrow-key selector, updated model names * fix(setup_wizard): use sysconfig binary to start proxy, not python -m litellm * feat(setup_wizard): credential validation after key entry + clear next-steps after proxy start * style(install.sh): show git clone warning in blue * refactor(setup_wizard): class with static methods, use check_valid_key from litellm.utils * address greptile review: fix yaml escaping, port validation, display name collisions, tests - setup_wizard.py: add _yaml_escape() for safe YAML embedding of API keys - setup_wizard.py: add _styled_input() with readline ANSI ignore markers - setup_wizard.py: change DIVIDER to _divider() fn to avoid import-time color capture - setup_wizard.py: validate port range 1-65535, initialize before loop - setup_wizard.py: qualify azure display names (azure-gpt-4o) to avoid collision with openai - setup_wizard.py: work on env_copy in _build_config to avoid mutating caller's dict - setup_wizard.py: skip model_list entries for providers with no credentials - setup_wizard.py: prompt for azure deployment name - setup_wizard.py: wrap os.execlp in try/except with friendly fallback - setup_wizard.py: wrap config write in try/except OSError - setup_wizard.py: fix _validate_and_report to use two print lines (no \r overwrite) - setup_wizard.py: add .gitignore tip next to key storage notice - setup_wizard.py: fix run_setup_wizard() return type annotation to None - scripts/install.sh: drop pipefail (not supported by dash on Ubuntu when invoked as sh) - scripts/install.sh: use litellm[proxy] from PyPI (not hardcoded dev branch) - scripts/install.sh: guard /dev/tty read with -r check for Docker/CI compat - scripts/install.sh: remove --force-reinstall to avoid downgrading dependencies - tests/test_litellm/test_setup_wizard.py: 13 unit tests for _build_config and _yaml_escape * style: black format setup_wizard.py * fix: address remaining greptile issues - Windows compat, YAML quoting, credential flow - guard termios/tty imports with try/except ImportError for Windows compat - quote master_key as YAML double-quoted scalar (same as env vars) - remove unused port param from _build_config signature - _validate_and_report now returns the final key so re-entered creds are stored - add test for master_key YAML quoting * fix: add --port to suggested command, guard /dev/tty exec in install.sh * fix: quote api_base in YAML, skip azure if no deployment, only redraw on state change * fix: address greptile review comments - _yaml_escape: add control character escaping (\n, \r, \t) - test: fix tautological assertion in test_build_config_azure_no_deployment_skipped - test: add tests for control character escaping in _yaml_escape * feat(ui): remove Chat UI page link and banner from sidebar and playground (#23908) * feat(guardrails): MCPJWTSigner - built-in guardrail for zero trust MCP auth (#23897) * Allow pre_mcp_call guardrail hooks to mutate outbound MCP headers * Enhance MCPServerManager to support hook-modified arguments and extra headers. Update tests to validate argument mutation and header injection behavior, including warnings for OpenAPI-backed servers when headers are present. * Refactor MCPServerManager to raise HTTPException for extra headers in OpenAPI-backed servers. Update tests to reflect this change, ensuring proper exception handling instead of logging warnings. * Allow pre_mcp_call guardrail hooks to mutate outbound MCP headers * Enhance MCPServerManager to support hook-modified arguments and extra headers. Update tests to validate argument mutation and header injection behavior, including warnings for OpenAPI-backed servers when headers are present. * Refactor MCPServerManager to raise HTTPException for extra headers in OpenAPI-backed servers. Update tests to reflect this change, ensuring proper exception handling instead of logging warnings. * feat(guardrails): add MCPJWTSigner built-in guardrail for zero trust MCP auth Signs outbound MCP tool calls with a LiteLLM-issued RS256 JWT so MCP servers can trust a single signing authority instead of every upstream IdP. Enable in config.yaml: guardrails: - guardrail_name: mcp-jwt-signer litellm_params: guardrail: mcp_jwt_signer mode: pre_mcp_call default_on: true JWT carries sub (user_id), act.sub (team_id, RFC 8693), tool-level scope, iss, aud, iat/exp/nbf. RSA-2048 keypair auto-generated at startup unless MCP_JWT_SIGNING_KEY env var is set. Adds /.well-known/jwks.json endpoint and jwks_uri to /.well-known/openid-configuration so MCP servers can verify LiteLLM-issued tokens via OIDC discovery. * Update MCPServerManager to raise HTTPException with status code 400 for extra headers in OpenAPI-backed servers. Adjust tests to verify the correct status code and exception message. * fix: address P1 issues in MCPJWTSigner - OpenAPI servers: warn + skip header injection instead of 500 - JWKS Cache-Control: 5min for auto-generated keys, 1h for persistent - sub claim: fallback to apikey:{token_hash} for anonymous callers - ttl_seconds: validate > 0 at init time * docs: add MCP zero trust auth guide with architecture diagram * docs: add FastMCP JWT verification guide to zero trust doc * fix: address remaining Greptile review issues (round 2) - mcp_server_manager: warn when hook Authorization overwrites existing header - __init__: remove _mcp_jwt_signer_instance from __all__ (private internal) - discoverable_endpoints: copy dict instead of mutating in-place on OIDC augmentation - test docstring: reflect warn-and-continue behavior for OpenAPI servers - test: update scope assertions for least-privilege (no mcp:tools/list on tool-call JWTs) * fix: address Greptile round 3 feedback - initialize_guardrail: validate mode='pre_mcp_call' at init time — misconfigured mode silently bypasses JWT injection, which is a zero-trust bypass - _build_claims: remove duplicate inline 'import re' (module-level import already present) - _types.py: add TODO comment explaining jwt_claims is forward-compat plumbing for a follow-up PR that will forward upstream IdP claims into outbound MCP JWTs * feat(mcp_jwt_signer): add verify+re-sign, claim ops, two-token model, configurable scopes Addresses all missing pieces from the scoping doc review: FR-5 (Verify + re-sign): MCPJWTSigner now accepts access_token_discovery_uri and token_introspection_endpoint. When set, the incoming Bearer token is extracted from raw_headers (threaded through pre_call_tool_check), verified against the IdP's JWKS (JWT) or introspected (opaque), and only re-signed if valid. Falls back to user_api_key_dict.jwt_claims for LiteLLM JWT-auth mode. FR-12 (Configurable end-user identity mapping): end_user_claim_sources ordered list drives sub resolution — sources: token:, litellm:user_id, litellm:email, litellm:end_user_id, litellm:team_id. FR-13 (Claim operations): add_claims (insert-if-absent), set_claims (always override), remove_claims (delete) applied in that order. FR-14 (Two-token model): channel_token_audience + channel_token_ttl issue a second JWT injected as x-mcp-channel-token: Bearer . FR-15 (Incoming claim validation): required_claims raises HTTP 403 when any listed claim is absent; optional_claims passes listed claims from verified token into the outbound JWT. FR-9 (Debug headers): debug_headers: true emits x-litellm-mcp-debug with kid, sub, iss, exp, scope. FR-10 (Configurable scopes): allowed_scopes replaces auto-generation. Also fixed: tool-call JWTs no longer grant mcp:tools/list (overpermission). P1 fixes: - proxy/utils.py: _convert_mcp_hook_response_to_kwargs merges rather than replaces extra_headers, preserving headers from prior guardrails. - mcp_server_manager.py: warns when hook injects Authorization alongside a server-configured authentication_token (previously silent). - mcp_server_manager.py: pre_call_tool_check now accepts raw_headers and extracts incoming_bearer_token so FR-5 verification has the raw token. - proxy/utils.py: remove stray inline import inspect inside loop (pre-existing lint error, now cleaned up). Tests: 43 passing (28 new tests covering all FR flags + P1 fixes). * feat(mcp_jwt_signer): add verify+re-sign, claim ops, two-token model, configurable scopes (core) Remaining files from the FR implementation: mcp_jwt_signer.py — full rewrite with all new params: FR-5: access_token_discovery_uri, token_introspection_endpoint, verify_issuer, verify_audience + _verify_incoming_jwt(), _introspect_opaque_token() FR-12: end_user_claim_sources ordered resolution chain FR-13: add_claims, set_claims, remove_claims FR-14: channel_token_audience, channel_token_ttl → x-mcp-channel-token FR-15: required_claims (raises 403), optional_claims (passthrough) FR-9: debug_headers → x-litellm-mcp-debug FR-10: allowed_scopes; tool-call JWTs no longer over-grant tools/list mcp_server_manager.py: - pre_call_tool_check gains raw_headers param to extract incoming_bearer_token - Silent Authorization override warning fixed: now fires when server has authentication_token AND hook injects Authorization tests/test_mcp_jwt_signer.py: 28 new tests covering all FR flags + P1 fixes (43 total, all passing) * fix(mcp_jwt_signer): address pre-landing review issues - Remove stale TODO comment on UserAPIKeyAuth.jwt_claims — the field is already populated and consumed by MCPJWTSigner in the same PR - Fix _get_oidc_discovery to only cache the OIDC discovery doc when jwks_uri is present; a malformed/empty doc now retries on the next request instead of being permanently cached until proxy restart - Add FR-5 test coverage for _fetch_jwks (cache hit/miss), _get_oidc_discovery (cache/no-cache on bad doc), _verify_incoming_jwt (valid token, expired token), _introspect_opaque_token (active, inactive, no endpoint), and the end-to-end 401 hook path — 53 tests total, all passing * docs(mcp_zero_trust): rewrite as use-case guide covering all new JWT signer features Add scenario-driven sections for each new config area: - Verify+re-sign with Okta/Azure AD (access_token_discovery_uri, end_user_claim_sources, token_introspection_endpoint) - Enforcing caller attributes with required_claims / optional_claims - Adding metadata via add_claims / set_claims / remove_claims - Two-token model for AWS Bedrock AgentCore Gateway (channel_token_audience / channel_token_ttl) - Controlling scopes with allowed_scopes - Debugging JWT rejections with debug_headers Update JWT claims table to reflect configurable sub (end_user_claim_sources) * fix(mcp_jwt_signer): wire all config.yaml params through initialize_guardrail The factory was only passing issuer/audience/ttl_seconds to MCPJWTSigner. All FR-5/9/10/12/13/14/15 params (access_token_discovery_uri, end_user_claim_sources, add/set/remove_claims, channel_token_audience, required/optional_claims, debug_headers, allowed_scopes, etc.) were silently dropped, making every advertised advanced feature non-functional when loaded from config.yaml. Add regression test that asserts every param is wired through correctly. * docs(mcp_zero_trust): add hero image * docs(mcp_zero_trust): apply Linear-style edits - Lead with the problem (unsigned direct calls bypass access controls) - Shorter statement section headers instead of question-form headers - Move diagram/OIDC discovery block after the reader is bought in - Add 'read further only if you need to' callout after basic setup - Two-token section now opens from the user problem not product jargon - Add concrete 403 error response example in required_claims section - Debug section opens from the symptom (MCP server returning 401) - Lowercase claims reference header for consistency * fix(mcp_jwt_signer): fix algorithm confusion attack + add OIDC discovery 24h TTL - Remove alg from unverified JWT header; use signing_jwk.algorithm_name from JWKS key instead. Reading alg from attacker-controlled headers enables alg:none / HS256 confusion attacks. - Add _oidc_discovery_fetched_at timestamp and _OIDC_DISCOVERY_TTL = 86400 (24h). Without a TTL the cached discovery doc never refreshes, so IdP key rotation is invisible. --------- Co-authored-by: Noah Nistler <60981020+noahnistler@users.noreply.github.com> * fix(ci): stabilize CI - formatting, type errors, test polling, security CVEs, router bug, batch resolution Fix 1: Run Black formatter on 35 files Fix 2: Fix MyPy type errors: - setup_wizard.py: add type annotation for 'selected' set variable - user_api_key_auth.py: remove redundant type annotation on jwt_claims reassignment Fix 3: Fix spend accuracy test burst 2 polling to wait for expected total spend instead of just 'any increase' from burst 2 Fix 4: Bump Next.js 16.1.6 -> 16.1.7 to fix CVE-2026-27978, CVE-2026-27979, CVE-2026-27980, CVE-2026-29057 Fix 5: Fix router _pre_call_checks model variable being overwritten inside loop, causing wrong model lookups on subsequent deployments. Use local _deployment_model variable instead. Fix 6: Add missing resolve_output_file_ids_to_unified call in batch retrieve non-terminal-to-terminal path (matching the terminal path behavior) Co-authored-by: Ishaan Jaff * chore: regenerate poetry.lock to sync with pyproject.toml Co-authored-by: Ishaan Jaff * fix: format merged files from main and regenerate poetry.lock Co-authored-by: Ishaan Jaff * fix(mypy): annotate jwt_claims as Optional[dict] to fix type incompatibility Co-authored-by: Ishaan Jaff * fix(ci): update router region test to use gpt-4.1-mini (fix flaky model lookup) Replace deprecated gpt-3.5-turbo-1106 with gpt-4.1-mini + mock_response in test_router_region_pre_call_check, following the same pattern used in commit 717d37cc5b for test_router_context_window_check_pre_call_check_out_group. Co-authored-by: Ishaan Jaff * ci: retry flaky logging_testing (async event loop race condition) Co-authored-by: Ishaan Jaff * fix(ci): aggregate all mock calls in langfuse e2e test to fix race condition The _verify_langfuse_call helper only inspected the last mock call (mock_post.call_args), but the Langfuse SDK may split trace-create and generation-create events across separate HTTP flush cycles. This caused an IndexError when the last call's batch contained only one event type. Fix: iterate over mock_post.call_args_list to collect batch items from ALL calls. Also add a safety assertion after filtering by trace_id and mark all langfuse e2e tests with @pytest.mark.flaky(retries=3) as an extra safety net for any residual timing issues. Co-authored-by: Ishaan Jaff * fix(ci): black formatting + update OpenAPI compliance tests for spec changes - Apply Black 26.x formatting to litellm_logging.py (parenthesized style) - Update test_input_types_match_spec to follow $ref to InteractionsInput schema (Google updated their OpenAPI spec to use $ref instead of inline oneOf) - Update test_content_schema_uses_discriminator to handle discriminator without explicit mapping (Google removed the mapping key from Content discriminator) Co-authored-by: Ishaan Jaff * revert: undo incorrect Black 26.x formatting on litellm_logging.py The file was correctly formatted for Black 23.12.1 (the version pinned in pyproject.toml). The previous commit applied Black 26.x formatting which was incompatible with the CI's Black version. Co-authored-by: Ishaan Jaff * fix(ci): deduplicate and sort langfuse batch events after aggregation The Langfuse SDK may send the same event (e.g., trace-create) in multiple flush cycles, causing duplicates when we aggregate from all mock calls. After filtering by trace_id, deduplicate by keeping only the first event of each type, then sort to ensure trace-create is at index 0 and generation-create at index 1. Co-authored-by: Ishaan Jaff --------- Co-authored-by: Noah Nistler <60981020+noahnistler@users.noreply.github.com> Co-authored-by: Cursor Agent Co-authored-by: Ishaan Jaff --- CLAUDE.md | 5 + .../docs/proxy/docker_quick_start.md | 73 +- .../my-website/img/mcp_zero_trust_gateway.png | Bin 0 -> 301348 bytes docs/my-website/sidebars.js | 1 + litellm/__init__.py | 12 +- litellm/_logging.py | 8 +- litellm/cost_calculator.py | 8 +- .../focus/destinations/factory.py | 7 +- .../langfuse/langfuse_prompt_management.py | 6 +- .../integrations/vantage/vantage_logger.py | 8 +- .../litellm_core_utils/default_encoding.py | 5 +- litellm/litellm_core_utils/litellm_logging.py | 288 ++--- .../prompt_templates/factory.py | 16 +- .../adapters/transformation.py | 2 +- .../llms/base_llm/videos/transformation.py | 16 +- litellm/llms/custom_httpx/llm_http_handler.py | 20 +- litellm/llms/gemini/videos/transformation.py | 31 +- litellm/llms/moonshot/chat/transformation.py | 18 +- .../llms/runwayml/videos/transformation.py | 39 +- litellm/llms/sagemaker/chat/transformation.py | 4 +- .../llms/vertex_ai/batches/transformation.py | 4 +- .../llms/vertex_ai/videos/transformation.py | 39 +- litellm/main.py | 4 +- ...odel_prices_and_context_window_backup.json | 47 + .../mcp_server/discoverable_endpoints.py | 55 +- .../mcp_server/mcp_server_manager.py | 72 +- .../mcp_server/rest_endpoints.py | 12 +- litellm/proxy/_types.py | 3 + litellm/proxy/auth/auth_utils.py | 10 +- litellm/proxy/auth/user_api_key_auth.py | 5 + litellm/proxy/batches_endpoints/endpoints.py | 3 +- .../mcp_jwt_signer/__init__.py | 84 ++ .../mcp_jwt_signer/mcp_jwt_signer.py | 891 +++++++++++++ .../internal_user_endpoints.py | 3 +- .../key_management_endpoints.py | 10 +- .../management_endpoints/team_endpoints.py | 8 +- .../openai_files_endpoints/common_utils.py | 5 +- litellm/proxy/proxy_cli.py | 15 +- .../response_polling/background_streaming.py | 9 +- .../proxy/spend_tracking/vantage_endpoints.py | 31 +- litellm/proxy/utils.py | 23 +- litellm/proxy/video_endpoints/utils.py | 4 +- litellm/responses/main.py | 60 +- litellm/router.py | 23 +- litellm/setup_wizard.py | 668 ++++++++++ litellm/types/guardrails.py | 1 + litellm/types/proxy/vantage_endpoints.py | 3 +- litellm/types/videos/utils.py | 4 +- litellm/videos/main.py | 34 +- model_prices_and_context_window.json | 47 + scripts/install.sh | 143 +++ tests/local_testing/test_router.py | 3 +- .../test_langfuse_e2e_test.py | 66 +- .../test_spend_accuracy_tests.py | 13 +- .../interactions/test_openapi_compliance.py | 24 +- .../llms/xai/test_xai_cost_calculator.py | 52 +- .../mcp_server/test_mcp_hook_extra_headers.py | 707 +++++++++++ .../proxy/guardrails/test_mcp_jwt_signer.py | 1103 +++++++++++++++++ tests/test_litellm/test_setup_wizard.py | 188 +++ .../app/(dashboard)/components/Sidebar2.tsx | 36 - .../src/app/(dashboard)/playground/page.tsx | 66 - 61 files changed, 4695 insertions(+), 450 deletions(-) create mode 100644 docs/my-website/img/mcp_zero_trust_gateway.png create mode 100644 litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/__init__.py create mode 100644 litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py create mode 100644 litellm/setup_wizard.py create mode 100755 scripts/install.sh create mode 100644 tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_hook_extra_headers.py create mode 100644 tests/test_litellm/proxy/guardrails/test_mcp_jwt_signer.py create mode 100644 tests/test_litellm/test_setup_wizard.py diff --git a/CLAUDE.md b/CLAUDE.md index d9061b5e2b..f047812018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,11 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components: - **Check index coverage.** For new or modified queries, check `schema.prisma` for a supporting index. Prefer extending an existing index (e.g. `@@index([a])` → `@@index([a, b])`) over adding a new one, unless it's a `@@unique`. Only add indexes for large/frequent queries. - **Keep schema files in sync.** Apply schema changes to all `schema.prisma` copies (`schema.prisma`, `litellm/proxy/`, `litellm-proxy-extras/`, `litellm-js/spend-logs/` for SpendLogs) with a migration under `litellm-proxy-extras/litellm_proxy_extras/migrations/`. +### Setup Wizard (`litellm/setup_wizard.py`) +- The wizard is implemented as a single `SetupWizard` class with `@staticmethod` methods — keep it that way. No module-level functions except `run_setup_wizard()` (the public entrypoint) and pure helpers (color, ANSI). +- Use `litellm.utils.check_valid_key(model, api_key)` for credential validation — never roll a custom completion call. +- Do not hardcode provider env-key names or model lists that already exist in the codebase. Add a `test_model` field to each provider entry to drive `check_valid_key`; set it to `None` for providers that can't be validated with a single API key (Azure, Bedrock, Ollama). + ### Enterprise Features - Enterprise-specific code in `enterprise/` directory - Optional features enabled via environment variables diff --git a/docs/my-website/docs/proxy/docker_quick_start.md b/docs/my-website/docs/proxy/docker_quick_start.md index 4975c76c61..58a5660475 100644 --- a/docs/my-website/docs/proxy/docker_quick_start.md +++ b/docs/my-website/docs/proxy/docker_quick_start.md @@ -5,11 +5,76 @@ import Image from '@theme/IdealImage'; # Getting Started Tutorial End-to-End tutorial for LiteLLM Proxy to: -- Add an Azure OpenAI model -- Make a successful /chat/completion call -- Generate a virtual key -- Set RPM limit on virtual key +- Add an Azure OpenAI model +- Make a successful /chat/completion call +- Generate a virtual key +- Set RPM limit on virtual key +## Quick Install (Recommended for local / beginners) + +New to LiteLLM? This is the easiest way to get started locally. One command installs LiteLLM and walks you through setup interactively — no config files to write by hand. + +### 1. Install + +```bash +curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/install.sh | sh +``` + +This detects your OS, installs `litellm[proxy]`, and drops you straight into the setup wizard. + +### 2. Follow the wizard + +``` +$ litellm --setup + + Welcome to LiteLLM + + Choose your LLM providers + ○ 1. OpenAI GPT-4o, GPT-4o-mini, o1 + ○ 2. Anthropic Claude Opus, Sonnet, Haiku + ○ 3. Azure OpenAI GPT-4o via Azure + ○ 4. Google Gemini Gemini 2.0 Flash, 1.5 Pro + ○ 5. AWS Bedrock Claude, Llama via AWS + ○ 6. Ollama Local models + + ❯ Provider(s): 1,2 + + ❯ OpenAI API key: sk-... + ❯ Anthropic API key: sk-ant-... + + ❯ Port [4000]: + ❯ Master key [auto-generate]: + + ✔ Config saved → ./litellm_config.yaml + + ❯ Start the proxy now? (Y/n): +``` + +The wizard walks you through: +1. Pick your LLM providers (OpenAI, Anthropic, Azure, Bedrock, Gemini, Ollama) +2. Enter API keys for each provider +3. Set a port and master key (or accept the defaults) +4. Config is saved to `./litellm_config.yaml` and the proxy starts immediately + +### 3. Make a call + +Your proxy is running on `http://0.0.0.0:4000`. Test it: + +```bash +curl -X POST 'http://0.0.0.0:4000/chat/completions' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer ' \ +-d '{ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hello!"}] +}' +``` + +:::tip Already have pip installed? +You can skip the curl install and run `litellm --setup` directly after `pip install 'litellm[proxy]'`. +::: + +--- ## Pre-Requisites diff --git a/docs/my-website/img/mcp_zero_trust_gateway.png b/docs/my-website/img/mcp_zero_trust_gateway.png new file mode 100644 index 0000000000000000000000000000000000000000..3955cef0553247c7730aaef9f77dc24219c2a6c0 GIT binary patch literal 301348 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�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 ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx index 693dc6b20e..2b4b4ace49 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx @@ -8,8 +8,6 @@ import ComplianceUI from "@/components/playground/complianceUI/ComplianceUI"; import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { fetchProxySettings } from "@/utils/proxyUtils"; -import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig"; -import { MessageOutlined, CloseOutlined } from "@ant-design/icons"; interface ProxySettings { PROXY_BASE_URL?: string; @@ -19,12 +17,6 @@ interface ProxySettings { export default function PlaygroundPage() { const { accessToken, userRole, userId, disabledPersonalKeyCreation, token } = useAuthorized(); const [proxySettings, setProxySettings] = useState(undefined); - const [chatBannerDismissed, setChatBannerDismissed] = useState(false); - const { data: uiConfig } = useUIConfig(); - const uiRoot = uiConfig?.server_root_path && uiConfig.server_root_path !== "/" - ? uiConfig.server_root_path.replace(/\/+$/, "") - : ""; - const chatHref = `${uiRoot}/ui/chat`; useEffect(() => { const initializeProxySettings = async () => { @@ -44,64 +36,6 @@ export default function PlaygroundPage() { return (
- {!chatBannerDismissed && ( -
- - New - - - Chat UI - {" "}— a ChatGPT-like interface for your users to chat with AI models and MCP tools. Share it with your team. - - - Open Chat UI → - - -
- )} Chat

k$ez1@E~m^s%tb)ASor$q>&w5 zUBH!6Yw&2!lsXlU$aBsin0Hnrm&lfvIp5RmwGJ6__!+o2R;z~_ zXP*kUu2^w}BDLUaCFKEAT1%#};i1==Qd5;pX@Ta>WbV@DVF*kz2{LBN6dER)xjQH? z!#|Ft>q4lH8VN)N;T}fTkH8vM)2uIB%bkDaJI#JsEw*xl3p5;-o}0+z6y(r-dvEor zkhqRc!a>IKw_xwxBr)*tqxaU;tK2u>hnK`;L^RDA+Cx|LgkQ z#4Y;EmI-FVgyUU@Jo);XIsez?G>s_d+)Y5zvK(BXWZrDyrqWEE4kM7E$E}27=JfQM zDEn-I!lWkU_FYa)FIr^!zvvh`Xy zJ-u_}I?^r&zbk?ltH8b??al;ug`I-x?LG|pAPBtdROVqEt-KdMn7#2t_#spq=>6kX zz-3DryLC{TsxE-zaOwv>ZcC>+c)F@ChFq5I<+`+B=yP6^yf z-Uh~*;|&-sFc!q#`|aB|u4*|sIa3y(P?;gO{Gbj>L-h#|`aCz*>_vrY;$jkn{@=d5Tc5g+YKS5qkqAVfthS>wOcXf^V`J6E~ zCLiMAnOpp`bb)>6cJcmKlGB5)?-SghJ+9^P-givTD-tJ?8Y%Yn?b{1kCqg2~p3|MG zcp$D&s5d^QLfi1@3!I2_&q@Mg?&OUo?ws0n!o9$Y5yJ{_xvKv~;14Bqxi+~FD~ZeE z6Suz>glwgp5o~YgTk<5|!7;xZ7;o=O_Mx zOZoH4zMk^6&1&eIs)mMzBn(XGfR4H;!*psnBG7pPT?d!&w-06Pr3Li$d0jhiU1MeS z7e^fKmAFxzsJN4ka(o2evF{iaGq*M=xuoUo(#+S(-l}6-{hBO1$@tSOH?`$(SWs6= ze6wpyT_auwPEuqRkTdBpURM028?h!zr>7N;9kpg@$8a(F%}8MN%oPALi2}Tnes&+9&!E|Dv+$ReYP_H9UK8@>BQ19MQ~~9>`h^5RMSo8zJ1yCMvY%nAC!&I{xM2 zZwln$P5BZLTYIW;`%^Un&B)z>(Xy|23X22!*HgD&=9GRQkWYn;P`-Ik6!JEI>@6tjKYFhxab=S)6GjZ_{2D9Ao(lpOQc+SwZe zNbh>UX_-iR(&o0Xhi&biPCqS2mN7t z)=w%3Np_5~OONn7xO7d)E|$zbuJCrDBx3*-{;qtb&9S@LSJw8!TwUu~Z{|U2(%3w1 z)Cr4T%t0(s;rv07YqrJq(W5z>YlDW=$96l5x%ns@6C+ye8cSR?)R=ZCOInAYP9`KK zjz?%Ul3sD-Z&XvnCOnU*`CL?@S{*Kpy-wmJ*t{8`aQhOomr99iL0DZ-p&}NmfXZl* zIPHxvq~g7`f84jUTz-Rch^Uu3sP!}n;y7V=9N1^etA_Z}?YlhA-1z-cZct4YM@_9- z0+Fn|5{oCqUU~1I_o;Y(y0**HzJ1o20>)<43o`?%Tr?)r-&trxu9!CN_&F|`#XceO z>!r_@!4rAlx}ahV<6mOhZ;8|;nP1a*pn;oFOdfchsrL~aa7we<9w~uq;_|)*u1jNG z4xP0Yxtdd$YWdX6Y(7YbpXTt@$LPO}1%kE&uO21L@ zL_wiXm4EA}dm+)r;;;qKUwtr&zXBg!nqkP!>%s}!LyBt+ke+VZj5Qepkw#(9LfN8#ijVcYN#ddm@IYZdJ8Xl8vJ!%z140TEV4DXnwun9H z&&gX+n85# zU(VcYFkO!wJrCEW#9+1ZHeFli(cTN=!jX;mMP{VqILU_tEG%S?Ov2W71GWt7DYg6b zEG&h=QoFoBXH@tAhq;Q6`9>)@-oP6;QnnU_b{>gGxgHn;IBhw?g8^Cz>mn$-oc5OM zaLyN9Pcz)SxndGjDv&zcbQ(r!B6sx`$G`LqLtdL}Yc%5#cuI0|a`wo{CTxH>1+c14 zOb@}#r4FdGJ|?TB;Q5hz8VvaMzI!ovnKuTHU{K1jthmc9Dv_^&D4yIq)Ujlh zD+bSRTgoYq_+(Cd>PWY6J4UTO>IY~H{5}aQiCa~uu?j)Gf~WKlnvKj^#htkvMurY$ zZJQy%`#2e_nL@SQ6G#9&U%9OB4uK8XJox>)GNT>8tbtcMZdeSrA7_%Fibn7{w{bBy zz%+ip7+*(cXC_}}_Eut=Xw)!vlnX3PEWARH1BW+|@0vm+p~*5lRYV-Z$fO%LB$}%ys)8=eiYHGNg1>^L1T&bJMBpv?Avi*ec{IVSgWzC z#yoM$%H;7H?m)UO7=A#3wug5%x4}?%WB>WQJs1e@?+O*4tzW;7T)vXJGb3_Sk(cI) z5}+v##}-`49{{3jzZ{6QtbQ4-NWzv^XV>Hg{9y>sDd~m}a}s2AeR3V#%_IZaKskvt zPLGQez0{A4wR|E%K*5T1RDOKIUixMd8rH!=!MD)1pZx%Dj+z8oJTJ_hU{}8Mu$f>X}f4 zG+KFzOBD^w#zp3Za0uxXihq8aiou|3<_@~Fxtx=*^6Xt1wD6?h2uMl!4po2)IUo{d zY#HB5QEQ!?T{RM)aCsldYGfn+X zg%p0siwU!&;=E$iUZjIMAnXY_kef;}jU}N2yY8eQX4B5HmXHY#o#fbB4T`F-A^VB}2!E1cBXYpLa9Z zNm&=lis&))fxy9bi&H8_B<$}^@`HAdBy0HPx>L5taPsV7AGPKfJ3>f=fm{<7(he1` zoVyN#vqI&Qzt3OqVKHf$K(IAI1icV|nOONp#A8kf;HG8zAr2wE_GQs4xX36e zo~59WkUojr!wmU};K++siP+?GzyR%+_%zBu=GP~2&PPwRdBoV?KP?nVo?h##1$q|NHI6e-fGfL`lT9st58tq%jFMB;_KA}O^!3ny( zg5a^r-G#hhQ6Ao9-K$8HNg(eM<4K#%GsABU^Qqzh#4~0D8H>aKH9%&tBHy;!4gRQ; zFhdrP@u7Wb1=-S3;JqiFdNw_*rRMDn{R9^!{MMc_yQ~9B4K=lS!n_A(e^0SUfAqV| z{lYH$TuettCtmOwB6+cVL4AIH{kTP2If69+HMq96mhX!Q4*BNnt*WNnolTd_4;FoG zn30Y;fOFuHv{&7fuAF|mmRt@sD`Dg7P-8| z4Bw_X{uhQJE$SE^)A7uBLxo4zvF*=De0d(JolJN0Oa3owyO-B3PlWlVyYaL ze%@nbV94?8?CR13R;CXo_?sYTE0@NqB+uLn_8dU^Q(Y{*OpH71fgIGaK5ln291zIwZC zmJP4xf;5 zVX;p9|b>G@7BQX98S;e8_-fV4cD1i*k|)*P%NCu2dkwM1X3yrw#$r zkiHGEnn^%zE_=*`ekL+t2+v-FX@55(x|GH7GjB`|gr<397QqI9zq=JWNJJw<1j+2G z?qmDR$nSPrnuje1C;xk4X5!{pqBk)6fnoHw-ocNM#P}JiD$K`~=?pb-UPi(zS{Aj^ zv1)LZead?AJ6k{BF;7$QBT71D_rp6Rfyl42QlRYl$SXobmg7u8tR6?jf00*CRdsHZ zc;Z|)nj3^x_pTrwbiA$QP>00WM{W4+s33K&reT7EO+Y<&J^_eIRs6a!}YJp z{pVylFX!e?Mn+Gp&3obw{h4b(Yb1Gfl%9D1DtI`^Zizm8SRbTkMCN&~989bc;y5zp z{@QJkxbx?QuR~GgOnH-w>T$6sq;scUrl7%>lqYy*7)sf7c|}D7Agg^4KOypd#Xg4c2%(im#SMkm_&Q1`01iglkSr|2t-oj8!1EIt#k$U84{K!P-6Jn0 zpFc@6-6t^U;+7)Je-Gd(;iuy8R-ayL8yg4GS2RaYsL|tzsKs8zKOwrdwq~F5ZEXqv zv67cC&sr&>4;*f2ke~_;4K-k3VA%im*wf8$&)KlV3K9NRKRCOHFLR3-BFDb0H;UHC z+oLZkDCW+Tu@_73DP5$e@ znht+hPLdCfTtxK0vTRYXjyiAguZ`$4;Elo@*&#vxL*6WKag#i30MO~~gYy);+@j8_ z2h2^nE=(erv;xqDpbfpG#o_sQz!zLroVJs}vuFeQZ1ilZ)FdE59D=+TJ><>?Cy&|l z=dFk<2Ew&>QN?d>b%tXKQG_cfDc#m1MeT(dMcw7)o!dkoY+hJxbCsj_+VGs2HgKIV zaQ;}x|KmP2Em@IP^~u9c@vqkdk+}T-2UoaVOaP(|49znIJ=IA~GcqcwB1nlL&|`fY zfpZT$RLMsb1?knmBGZrtb*l>)$1H1s?oeG{S67Mld=5M&z%e3$>O-quc)&>CdB-5nh|P@9EBN1OAc0rm|Bvyf%h zKw~4>7fR$DNV)qfUCKa>_lQ&x@_R_4HxM_RT`;mi-~a&__VYp=;bcPIkEj@cqf`NQ zor{~6gaTW!AX-K`t007re2efAVB=L_5P#5-kr9Rt!zk&Gw?}Y5t5UZEkR%$Zta+t| zYhg1W`U{I{>wxcLSj~*&ZWrM9Xy^0<86mS^w{{kxodi63CJ+z*^Xia~iq)oucn4!{ zbS&l|cgHQ*_fOibjnWNc2)cLg9I7phK~+cKG@UK(ZM6qp9J|tG%L3{&Wl28X}LqrdWi4mM!I@89jQEFYkn|w-(RwhA+ z=<$^2%mNISEB9G>{;c+d&t=Huhj(Se4UBqvz`K-Hw~q{JuVexCMJOo?PY@lZ-iN(tEL z75h80+_!E$Z{kFSq=0dW6}K{?{f=2cKng8XCXuK9lrtB|r&ndpenAFZOm&!nFaRqE zK?L!5`6$e+-;TwG;!lT3H_B>yAH9b>nQGxF&ydue_E~oT)0{{{GD@TUYK`s|FwYRD zpWCR1`QruzKmYww4nNCgKRjTvkER5al64MCxjW#cy%i<{H3$C{ZC+0Un5W(Vd&L~~ zQ{J{*n8(B}_#^f6R4x+$mjk0u=BaXp*ZTXh>-vGAp_6LRkVo2w-kvMS7+?N!IP{?D zparoB=`6tH1~Uj{hl|o1@T^g=QT}N8ae_)%2;dq`o)xfQYHtKTH}C-7wn=UR#=1@W z3h-LsfcxmaSUQUga*u*t4TQ}9e7SYK{Ih(7QF{1M!dHM2O8*$K6^8D3jW!z*q(8^w$v*<)n#l=)SL>>n?ys6zpkcI1(fQO<9)HB8z*nUJkGs&z(u43o!c8nCfru}DoiM6fEb|hIJ?bb&Cmk!zk3UIg@v~C( zL=&SC$A81FL(cw|5N3NodRQ(`^bI7cEkbpjh~{UB^Qr3MYP!ROHk z*LGzS#9isY={Uv-UH)V#81A{s0rpu2NZJM+>RxBWz^xHxuQ^PK0bpFQJrPM6y?h^x z`2{j7D~GB%h8$;^7soKTqJxQX7y~WFD!861(+iJ;zgYMP32Hm6GV`$ch*%3Pe?E4? zrhKE%L;wL~mP?V_ZqWih7IQ$;UbBQ&LQzRWLql#=Rh8)X*Hs*#i76K(^V`Wi1VQlJ zwr{eqY-9rQ^;@?9>WlPo(pr}h10gsS zE`!codQ|v!4J1nXYF@&epCDsBb_ZJPbK+Hd7p2%b*2t26qHN+d2_Y|822k34ssNRO zUuB!pX?x?8mlR=o>k1bY0paS|ap8zcv*QN;dJ&=`4+z#imelW_ozg|joX ze<+Z%MPLIQAxB}TgBUBE=(BNGl?y`>mwdrrf2(**_T6+9$hQj}@$bV%w}Vp{F>0g= z1%69n6-WLMG#ViuK{KK}=IZ~@Ced7{7CMa0Auo?|5?`Z+um+lZb~-*0;#kX>PKHQU zPidGB*6)53sGO8k+}NcvMiv%D0N7!xZ@~57C-(pc1zC_H*Ylg}R}ovN&_0$Ea`a21 zh*z(cmOv=0#)dTYr3mk%Y)Fd6q%lJJ0W`3)aoO*xy`it`5&gICh0$4Y2f0Ed@cJ2I1=)$34Xn&pGQGeT8S zQIW@TU+XgBdY2jR3XU~Cj>U&EEkxQ=m#Q&BMz zq`da9LF~jd2|V>+$Xk{B{hN%8kOBi+1~k0b|6G`oPjlsVZYbTwry;>* zx3RCGtZxX6h}QD#-?)L;_qcf$0wBo>OYN}0Qpo#%0pIY`@KOi@d_fW+e59mIMOenE zFew+7LjJZ3V8vWD5<}9{(((Z6AP4Ix*aeVaAf|a#m;A(CD*pKUltHFMRphj3&I*`< zb8%8Qiy(T%$OnNZ=HRw1ZzJg~KrO&hZmBa0hw}<_fD^M}0RdXj`4uoneD5J_?k+(Q zL6yAzM)VriG~qDtQ#vo*i;3x$gktr7I48c${Gpbn3sKXGj1V^Fm_Ek$tfHqq?x3ru z_Y7pic0l8y)Iay?#*>)>*m^?Z&?iGMnqtU&>{Dak^i~N9f3JZiQNkA@TB?T zoya33_bn95Lgy7{%ygfBIl1dp-oeJh&iQk+`{`o-1KPocv_fTdEuSMwYA5gCNEaN^ z`;@5UA%FAly(9kzbcRdOc>t$9^TiXEDHl%sgK3zp4|`?m`}2HgYRchf41% z^WCP22+*5uJ2P8GW*s~7A%wCZC@A&og;Yg%dX!}MGe_|xJzYH~`YH)FUcRvrf_Sr_ zkFTyZHZx~GaCUhsf$h}}dwpP0y4=&Fdxvddgzm!V=(2V$nr|p(uheI`o}G@Xrjv=E zi~0O8Pj6tg!h_fKTS`ooGZ%oit(zbs!c9V+vmebuWw))G5t{Mnl38|9o#WkpQ^Q!+ z{Rprby+|Azx8#f7(l`j+#HYlyjScKpb-(m|BYUtI_7>7SN{=aTkz|BMux7lL?G~ z!%jguIwNandbGX$8sjxlk^Y3A{vkWOCPudn_@gpxXM(A=i(e$GV@75*DoX_m=PK%q z7x;oMXA+(3@}bWb>yL;>sZoEW{>Mdp@%vx0L*OTh(t-SdL>L5Gz{A4Z(H9$^tq^49 zADl1&2VZfolNFeSPLq)60HZ|m&lyZZbhFcj>nmG?7_-5s3w2$~J4{8f-zO3DxC zuq3caX5wZi9-b)NV&6$Fch%N*&b>lKDDta%Ew|X|mjeR!t>v|MH@-OQd>27HN7Z0ww4T13wCtitkhYx zsESM6E4{3u=494MF(wb+E%tVfyz}VY!kU9nZOIFm8Jk;OX|hj4cHe;plvF_dfW4&E=<&?1b= z@T0uR#wMW8keV72!Atu2oG`bXd`FO%ri+?cw`<}MWI8j(E~?7T>eFq$^+rh!qcz|Z z^J0Z=*0@Z&?FZ9aeck@rc_*{FuJQXEd1llEYc|h-*Hms&qOU5}rJ$lwF?f^Pd>M1` z2N5~x@_y~D&}nicTc91z!Z+*lM$YfFf{%N&YS_Xst)pspZaHb zYP0_GEkEG5Fn#{TOXUVe-e0|xQ?DhTk1pFVw3&->_=mt1a|nUxp)uKU~bCPr@F-rELo z$2jxNQF)iADhjD(?io2amlY}BvlP<~s}d5ucP^pYz1w=3f7aL{=VhgN+1kQGDlE23 zt5GcO%CU+-f_K_9BI*{?ZBi;s70adM6r_cXHpv+osY88atbHZ&S!$bHy!yoNu0?`f zh1KRuLS^;G;Z!C8Q|r|NAi_ysUpXPT|LSX{dWK&CXXi6cn(A3i1N_!octWxO1VS|& z5?*3|zzu0z0dqIjAZKXk+px~^qRugc;nn?@JknC(w)q9U;wqUv&NaNeOmzD=*^w*A z!q4vhr^*)j91l-&-WYZ7aj#?J_l82BWlM4@^1Mz+>wKh!x`QJkLg+6##%24wi+3cS zS(N;7otdjigi~LF>oNu93gVC+(ja7!T%ays!5_7F%J!4mVDw(R72`8ZFYMm*@s$&V zAhB~AsN+(z#};LkRdiKzh!ZQYu{#gyrAj;Zk3O-h?5=d?qKsxCNHVQQ<>l1vc+q#F94nQ2dWudPd+eAijA{ssinO8vo(v-75J?J}@Z zF^WnvaSSCV#K6;rSkTof-QFeGKdt9B>5ub%RPshB^@s2r^mQkdYm&^Cacr7qkK|*_ z`C2aw$M{|1i`CgO7D-E=yK*esSzP-9H}RrXyH6ghGbNwvzlLS*mnC^gyyN8QRT|zM z9%mM4Qrgs28hvi^){mf^%9Kq2OaT=m$_2Z-9GdoM#A@XCr5jn777J0ovfIv~arS$q zFTIRD>Y5E2w-E%BFf$x-MwDj%D|Cn0a$V6GOfq}n`Unz_b=!hop~YPSOMo0TUlSDu zm}a9_{wf^J31bd^H_=FWv<*8LaGB&`%TjK+@msUO7ybo2JW7N8SxRV`WS3q4N?jhw z5hAT2no)NN)I(o+dvVL{lZe^EDc2be0q~z0)=2pBVQp)+GmCfKA<@HYDz2Q{`ct-b zRAM6Y9N_6GW#LY)i0e7_|KPE4+ZqtsU;O_1+szwd@YTUr@ zAQ^CK%`KY0g{<9Tia!}A^2y;V?Vdz~ZmY<(xe=TeLt%Ms-E|RO?F1Tbc0NO$oPck| z=^ve0I}*YxGf`{E5;o^H3a}A2MZEY@iH9ZIG*{32II8*8h-YNyS9ucSECvW-@#`fvN=@x&_H1F!3t+8E+ zI!3qzxAElXzCK3O!8)k4rD!%PL0DZ0QS$`a6vGJC{Lq~RYBcZ4xBCHEL1 zy2_n;;r0r(Nz0YBgbY|9m z;Td^JyKX{86L+7Lp7iSDFJj&A2IajH71Q%T`^?PD(_&Rpf_-f_`k)N@q}lC=t!5+C@yFae-y8qq&i%&+|Dkb(_w$A)?% z?N>w7o_6tT+-w4_O>27O+)lmr`xKE`IXx)_T1ejpOF#GV}cz3~G<Rr*Sgo;@9RRKout%8CuY9{6Igw7;cxMi z;R#9m!)~+2>A*pBu~KYtvs$E7^=)#ok&{_hBv<`sYqV!r?)H%=7h8vIcA#z$6m_4# zD&nrNjfL^+t7I+}UcNr8Ew3Vt`xvmVxVCawaY%yUrvxMQHj6D$E1zv@xsZ-p|9&mS zg(N(F#dP~QQtpXTL=8jHvZl1gwb43a0F14bgyUc!3~&7`>g8Sz(7R`-6)g^wO_p1M zk%|bnMby)CofH4;nd~e^|8bP{+xMd%gO{={lx|iaZSVW|%uv?=j={Jpvsun%Jf!%= zW{3j0?N*H$TZQE9Yo`b~;1Q&tWcQn9I3qsI!-D`f)^(X!u_Wa&iG zvi2RKDSVuF2#WyNIl$G)2I0$bzRCUnGguu?FJ~n0oxv=>x|w+C!QSVfqexv!NHo)K zX0y)t?c1)pgf=iquco7V$C>8ekWeh9B=xKv^BdqI?&?{O-W%f-$FYpo_xUaUXlg0a zG}-UVn+NwgF5NtFWv|Q4oHvO>RT&c-`9hy%hwj|JaAI!!=AIb$xQ7P%v6bHLZwQnK z`3v{=oms#C^|GpizW3cP24~+%yBm>hb!-+lt-JIMV!h>IU5WbzOl&1fu-mxPO_EMh zQffm4Pqr=q{lqT?NJwVv?>n}Xy+81HJW3riOD%Z$(C3|~#8Pcmj}SHcbvBwrn9Wh+ zIJ{1`3y3L>`$_2D3BB&GeQy$z_S=T54k8n2qo2h^ttDVf7>37bJg~@j{EQYV>EBt| zwU}4~ud2ML4%NI81#sAjkLj-@p5)w2teR|UBlJH%jIb=Ot>>p4LMXwDQN3SuA6irI zp+2i%W@pdxBchld_4TO^It=O{*akV{)0_#wU->wM&sXJt+T}m)&fK>K%$pdDlMn*! zy1#a`+$AjyirT)CQN+P3QhdB?avnIaI4az!*rRDypb`GnGW>gi7k!0ofm>MgSIP_< zEVa|}00{rjq>rHrzy$Mqkmkqw5$09o6qKQqke>jXsmEbX=TEa zo#F49j&D9)f@km9+opfzPV2pN{f_5XRVQCntp#@W$gOb%qE_Mb1_)!rGdTa->jP_n zHg2ASFUXYHpb}h_x_#9+E(?is%PK)Nlu>n7Pb%%}RJRV_TAi|+oa%Qv;_N))&um(6 zV>N2qlq|2J+n-s5v^R85C`hSvA}xaW5B_eL(UkAEeZLR1ObClZ`SYGyp}6W2kIg{X ze7l}$DG13Lv7&zWt0?+S5er|d1q$0oynCG&|2RrEj-OYQcg8&sXrrK7=P&=ZTk261 zo;wBMmO0e4Az2SvvO;kwdo_QdBGkFG_h}^4W}p5puOfUJ5jSk(W}7T2Ofp5S%!2$;nGZbxlmL?&1lH+JgJOo0Q5wvmQhLE6xm`@Mz1?ya}0&OHSmcC*2eBkjkO0gHAuYFW^6`c zFlUR|SjcK$LW?tm=0`^7tC?BfyM~M$_20lB;x_W01|Vqs&v2#qrz0x+K_-jt1F+^F z4g@*qt6cCd)sKxWE@D4f53U)rRf_73O(~tY=kYAgyLooHnDVyu&-upzYNI$1$V}(l zW1pp(B2DaJ;i_K-5S>jBqktlPgU4R?-1{gqQL?T@CoV~W)Isjc+jx{SAxce8h#oGM z1VsXP(uIbtkcincN=IAzj}RPlqj$F}$lH(b9oThAX75Y~n!$72Jpl+L(7;+Qua-k3 zD<&U0b$0d8&nLdv>=*n=^)s#5{5P3jKy>PKlK<%fWu+W&l;7qI&&vMn4yxjhomI1n zsxkZAic843Wmi6STT$-Qle9^ottd)GN8|W3Z647ZHijGaspx-pyZ{xsu{MOmQtq*) z7`LyQ=4XK+iId0pcmMwG(td^7`kWrcp{%OEf-F&$7Ka=y08ZIrVpCg=chxC51Xi~; z+uAW39UkGmc_SGRf*flRF;NbEH*(gMDG#v^XKEn?O%A;K+Q|6+0;MuiR@-$z)T`yQ z*1@I_%obkbt}~mh7Wr-Ig@^w^LECV&7B@-tJ3fNuvE{WeIj4GCJ2;}%peIYac)IUL zPh|9j_+Nz@-oF00%Fn&EJMs%i27EK^sy_ac>ZxaI?N<3+x>ub5JY)E{ZET`Y<4$GdZ!9#Z`0=hkB6Hnp&Ah#hP&eLficfup9d}^)5llt-J75EWj=~Z zrXM#y);^lXQ}YOw|uB+pVUW3CBL(&C0WWg|Du;LW|-FVk~%q^KqrvWI% z>XWmCNBlfQGCm(^{Sw6b)o<~@iML~DEqx;m&!8u0Or+p;(kztoS1*^y@!Nq5($&}e#%82yH z6##gI$B|sp&pHpfQcaZ$;YA7^d#Lb$v*idG<6^hXfTCKm(qWo!7$pDD`gLi}i z2;+hY^f${bjNc%;N4&>_r(BT0cCv?k<1ln;=%s7jnXz6InX$9OnmPQ?5zF?>}50 zIByJ6r@wpAbDo)we?yv#OzeZRE-LlDj_hxkc;#ltp+L54(n28Ltt0Vhq3*8E%Wt_|$CYfki@jduE6v9X+Q%y<%m)YpeP2I}`RtMm_3cm%3z<}=-btvB zTVJ=KRKiIBvzHqo>fQ4ESi$*(ghdgmBV!+!(C3aZyyQ8rz6H2>PnRUO;BDZATf*Gc zSJOWrCF(N&|GSERNDuFi^40vepriLyNWe3Tn@cYD$A>1`Fj$NiI9jYqI)C#L)u;B` zryWHQR!#Hz0HmuaR6+Z2_0SN56tbqoY3&60H7kjn-E;S2>&Ns;o3iu42M&g=T`y3guLb@Jc9g~1~%n$x=1gDl<^PBu)ywzAn&Jc`n~ z=SKv23&ex7#P3H;$9yf4U5ag~_BIY##Ff#zhThYkcZqsKYd=*|Z!-{(n)v=hqB<6o z*$Xv8)*JXn1;z?HwUOHTBB+=<%l+oUU~uvJ8D*#E(;Up5w7H#~*yI!)l$Yuim$LMRjl7f#+v0nf-j)=8ilfQ0!4^v$>A* zKs)B8k>z;iZ5Nf}W7oP)AF)+a?MbgW z{4jOD`STq%r!Vce`e<|T(#{3$ANEttLC`##sT0?~K^h+P#*%0);nd;m((srDYI6kY zug>x4VdzOG7);O9xFqGqiwUjVn_leJd2ez1dU-*FTq=$-To=gk%5p@7*(xGA%-Wev zTWH(&MrhQ+VA>5LCZPISQ205;*I`OZ+RPq?zoyE?mz~`cl8FYv@Q<`Q0Rw;hl`agLO7;RfVj##mc61_76|MyMsjcKvE%e zV*vs17EwB(zHg~LBL-Pt=^kJpZ+feE0w}Rtp8W&wuN)Pz===kV4f*_e(#}X+JC&pa zrz4$xoKAiCLcPWwa32!I?xrVB^oNQ!Lz+XyaNKRjq*crVnc!f9iXu<6XVZ2pD!X%Q z!CkQtvQA#unnN0ROf>*Oo*w_JN&bgS7yq)aqP$$=Vzr=Qz4LnY$8-E>^!#0`J-6k% zlp;?o-C5cm6H`2|3)9pyGR*lk=CbnqVs-kXuiDa`*fj zgeuC;v;O%Ny(d;bISmlBtjZkeerK^tL=Pf+LKN<1P)txgCIH#E#Unylw68Gsc0C&# zGDMNw7WJ$qZ$}wX1oF$zHqs3zPpnFQ)r>Zo2YXL`Re+@CmQ*UHxOhwKoZEGtpYN20jH|S+)t^TF& zUkavf7l7zg*3dY%E1oS<#&EvBZ4P7!1CCC>&DK6_cKgKJJJG41 z*RL6Ob@!66h7Wcd=bL-3coEDur)3(trxRvUyv<1*CKID-g?!mw(NsFt&+Tf@^V?{4 zR*rIE`ja{23Vy54M=?tFaUlZm2%;L={NSi{EiAw*>Yi0qNHDe;WPG$Ou3!JAn4lP0 z`Rc9=S*6g}l<1OzVOd_!wl(Zr-}6*e%?qX`0O8zH%nsjho0<7a)`*%})`;3(KAK^w zs)rIBxamQ$K9u`RZ+3q9@?9Fuyu596p+3JaiKyS3+41SHN5gnua_`)C#Bw5z-j{@h zTv$38Q*P&@gC*{BgOrz6HY$bVp!X-?${HsC58g{MIz{qO(m=2amsMdJbv8ECOMEntm_C5*^aQgiUEubA4SU7V@uS4E4oklZ_eeZKT(pC(36Huft^dXeLs2D}H*!+|#jznLPWL3!2)L>$N|D;Z?tWRANiOt%+70BSVPL*qQt0z%G zmpUb^C3vwpy=*u)-_Ho&;1_VP)jc9KLU4Q{&#f2VWLy|Qq|uiaV(PZHJ{|2mhg|B) z5tY@+`{-g=RXpQt55nCDUW?g^0~+sYGK4gO4Lpwqo3>a_`q)iwGN zZdP97YE`RDtm6Ey&kH^O#J-Jwxi)5$7(a?87^Xu!ZiVBM?&zQlcw z+^M6T<9anJHtOCbH>GrYD!}9^((bie7_|caIE%ss!{#1CoF8i}o z%#G6l0qbH*&k`JUvOC*55hwS`YUCVGJ+E@nj~GvK&Y^8qzbs4_L%pVET-lRKwy8ZC zs}vZxuJ~BV&TaF{@^gHwp#I16sHazi%eW4)i(z zl<0EA+dd5w6Raq)WFmJ6C+RIfjdKbKIoMZc6XkSjan@Nm!sqn-yR%k0CSmkcOqJ%1 zL>7>M@BROC#I~^d`IiF&l&qp&^^URhm#^z``r;dx!I`F$e1CW=@O4xA#|I_a zxz^{l{5 z#N;IQULY?@#s!JiD(>=od*W4LJo&5CmuuSJ(F#&_5o4&q9?_vsv}-6RS7DIsTq^6v)JX zUnAw_|NaNx3BWEi=Ra7J<4##hLXb?*Yi*+gTL8cOeYX*b3FWN7kCYI9_}Bv@$0=$) z@lz0vMc5RkM6#3ky&r;nSr`v2VW!4bGtNo}*E%BJU+ohW7`PF+Kp`)X5i6Vr=|wnT z)%}g^Boa{?Ytmu%&6c*2rQXP^R=r!JoXuofJ8FBMA9?%bCU?7o>w})%&VaA?&e*~? zAy>V_?|a-1pK4z*T0ok|9YBw&-}!?%|)JJXEN)pn}t%15&FGZ z9YiX(k`U*g->h7HEyBvQs-_y-gXD%f;KrI2ZTKN#+MX~UMb{#gFwRgTl(@P^tEKB% zWM+M>9n>2e4{e%@Z_bX#AIQipfX_a$vXBwXD+-CqQW6r*5HeBQNFm2}_7AFme_#b! zqq#XzhK~3#KaYj7y5WsZQz#e_)j`Bm!PQnWHj;a?f)a5yy(~-rmAMLx=j#NUpm4Na zO5}_*B&~A=qj%e@snnhMji43mch3V>51jB*S)8sJnO@O7lDD@qQsva~*aHH~))ihg z6=iRw)5RJE^GJ{B1bppXLkBD9l4)gSH6|ojkI)kN%}9(hB1vRsZ-t;8?Pc{C8(@JC!OQ)oS$F^z%tGpS3O+(13t-Ew}uSCmsFA zhrB!~KtguB*f=|qLswQ&7Rn=;5HCFtjP@mOlf{zGM=q5dop_hT+()PT(I#26DR{sM zr3g~@qx#n(s24kxTuc8v{k9x|KOjoq9?7u>vi)nOZj|Jf)?y;-oe3AmkG}qCce}GG zL=>sRG+Nt$CM1>O(@Xwv?o7hc8!Dxd#OX(#Ve;C#ET0 zSvhQ7G6EeJ${+DR7_~s=>N}|Uks}WlyWe2l2)cg(F44`-xus}ec=z_5FP|^t;m_ME z`t3$M5%3WH5E(&)nGYonwHAPo5-tNV*tlr#%g>srh9`gR=6e#Dy=|9>OS>#@Q`R5$ zp`;@Fg(wRx)d)%1sbfCfnax(>`lHoA2o!K#KZmAe)7zBBpe=T-&3HCpIHOfASZ749 zqX(t04S(&by_r!5+2#vfcL)Oce+aW?xhjtQyhfeo=DS{F!K%%BX zi)-?OG9>ZG2alvV&kYI8d?qJrF3g)dqi#5`w%{3^KtT?IFjzQ7?yef{xgK`ycs@gx zZTnPj*7fPvcbEV{QEG_oEcN?tK0sOP`>cT40rt{2r zDA(hbX;$188a-P-!ddUR`PdHF8vq@DAtQtbEsFQD<2XXZoJ=b1sGY6jFTV@>PnFJ0Zk0dGEU zZ%^(SNh$qP#tYAJ->=IwT1n5=qSaQ1%wvsvvh`>=J47ZVxmOh%M@o=R9_y+Gh7WdG z97z*_J9-vdit9!J_1uxOf7$BtN%OkP^85O(|F(}VyFX7kGeb*A{oJygY}2V2m8Ho0 zjt(PZjht5xjsf2~pD)%mTL0Xj-t&j5r8UP|&EgLT=!zkiIQL-7R(ssveox5gpJa4c z#YCz)+`}7Wl&M24JUVkkrlKg$v(r+LnP;}CoEp~!qwpy}F|@1OuEp0!_Kg(TV+ogn zi7;!cd%t$O8Wm>1Q}e++2;gJ)%`6?gN! zJ;+8bjVF^VqA^`_GZaJd%|Xh7Jxa68mfM$PCuGP_dw(iM=GM4&E`C$gE$!Egc79iO-DjEv=11Z4j_ z8I$vtFMjRzyQc|GcPnJ?sQj5zR11Lwn-U&l+)yXac|-PJl}&wui_{HcanP+<=ALKv zWDIbhe?tk=9FWwb=flw(>WZ$0fAuHl8^N=Z%G zjO8`GcUVV>p9i(e&%hZ7UBc-=sVAOF!yOGW)ucbvbr-F049y2m5x*xP=Y`fD3 zEtEOSxUw##terCJ3aDV0V(|Zg!@*(df z!?>XdueBtq7mX6KY?spELDBl*udy53R}ao?7Q#u0u-m|r9dz21p{VHyRu0?TDmXj) z`DL=z!=KKDO_{PA9XnjO?>@OxmuwnL`Lj0HdbgdjAhZpJ)69VTYzr0LM(h`B`#ze5 z=0oy4(B^N&lGii#%#yepgx*dQ z@d&xabsIYo&CD;t=9k*?>2ChM?JOz`0!`jXso(NC7=Hb?lVm^hr#A*F?<${mp6Hp< zymczZNN0Q0f~f+fE-??46&%Z`Rd{R>7|589IvwNumR(u^oxVcFp=dceMBfy-UfN1F zypa~x)iy^&C=YJxnDbo^@0R7ug-+)*b;qcDG0S-QB%lEH{Q+MhaA4*y?5dYf`$;Zqe*o z7{XA+D4}k?#Mwlbb{1q4fnnJuB-)0wU2?ubCkLG+(~Zop8*F7c8s^i)P3~%w35Yd^ z5Y-k!Mhzsq+uOF2h>4ht>fPxH)U9puWfgr66JlFY+_e5}VRLiPZ0sYzj^wKg`$ucc z%E#2y%ZhT#nPz2w|F)@p+}#mQj%^TmgI_+!xXF3oIUjhDPG?%5g0fLimvtE;Uz4jCHWoS3Jq zr)P_bpekXY8*+$CepcSQV_6O)4LpweA*#=RT9iw4A zk)6+OSRsU;(Kr>;^x^{%LpU#quJXFuQsJny+x=~H+`VEukL@se*ll`!hNgTBa_?vN zm4K8abl+3|dIjpBtkIml>Xmz2>H?UdHtShdtsYz{m0QwHy3skvda8$T{q^^8-18WW z+Y7ydwfdnCgNZD`)Rr$Ew=Y1{84pNhcFeBv@y38?ea|r3`ddcc@z(wTBHhk9ID0LO zKxqYYiFJ})Rz(P6SKz@a_usLCR!MR;EF3oTykilORARdQJrw18rt9OVjN#OjnM@iu z#nBnk|7kd!R=h7OnA=>W-Aj#J9F%OB+WzcR%M`ruREu0-LPvj2jQROA06TGvi^0H! zdtN8>SANmle}_Hq9cEwoh%;<{>^Exbybjk8PKERmfk>NWzU;btK-TNd#W2vZ2LGC~ zivU^c)}Z4nKxBQ?l%1d5PF=r`I}F*jlN_goD%R?XgiOuA>W>|y%@CM-lpzpd;G%_b z<%HmY7GDIn%9s0*B&I9VtcBhUi8B=Lxf&FHq33m|HZ`D|dW{jgl*SBl_yxJy<`{m` z2fcyK?jUmKA#6B&flTh52Te<&sCA^jrmgK#V93_yL|B7_ z?A}>&_V#277AC9J5FQYxKk)3Kqlm?-Iyaf?rbt>`-xg8#nK}f#L69AqY>2NVB*oY$ z@Qwn421QAr0i2mml|rnW&MKUPYRfzyH4~b^p%p3D%Ac={Ah_iQ4bCr&RjP+GV7!69 zx{Dy8b^?6yUsvRxlH&XRjfdZAj~f{9&zHX{|Mq2_tU?T0yOdCMam0J{HOVl4M6?BN z1$7MESW|4t`r{q+ORC)lr%Oq{*%C40f5vq3^+-nw!{;s;UTYuZdJ5#Ma`kMxYqcA~ zXPix!;v2!FYCoZDUd=XS+uCb#tDH&KQ1JTs%{^!e=&h`yQEU43qvt&~dmWA!fhH=|m zFECbC=QA8Y%1wQzQZNfvQbQ>N#wxLP7lZVGl=4JAhUUXM(wH~;zLDUQcCbZgkeV4s zb~bR{u7-H%7+9@whlS~&?8T0Jp$?6|od3hXXchGG8V!D2mN}`!0QKWpv@Ztk+g9?| zzMa*Yu2s!$dOC0E7^-hBqrkCG(ZB6M+3RDh(&R$RL!mRNSKbrScIhPw-h%`0^J-son#mA2 zClE}Vi;J_f%IeAz^yDM;idG9t9Ta|hd$YwBb`B4eP2k)=jAyI;MHNk<nH2HWkAHPZ0l(>d|&?Uu|&C?6W*DK%;|CMh}`M{A!5yj7_S>+ zQaqX3l=(=`HAy^?>`ZN&Y`fZ(^tzjJBZ^(`HL>CQVhd+L%=xqccwTBRUdjvK@RIN9 zMz2i}kcAMgPnW$|cc;ZogWjxWMA?;4ki2g7KJy=q!B*B56+D!D4zeFfM% zphhE`mRAjra=@vrxeJQa%GuUjt!UKd7Z>+y99BxgOm2pD85_+_keP;2?EVJ7de_I%5yp#zF;?01CU<`J<%d3{&8opos9!CLcGrItQM4owq zc6xCm-`(3g@p8_uzIjWIlfQ2${cA1%`a60LmY4rGpaa~EN!qrJId-i|(HOusm?@^^ znHMg^^#oPG{iMfG|N6D*6(%PKiV=mcMgZp|=?i?zKyGB2+1`tu?wO+XVI`1L*&Oow z-jSK+kd`!@y$%CrWG7-jUNe9{KJhqf|8!mnHEx0I{tI99o+*$472a2EW7HHTbGC)9 z-(_r~zrG2IaBG~kUVREktW>%|*H+l~CJ(H~(=^z&T>C6Z$XbCiMDx%TOYcaEL9jkH z4~g08)#HH#Piji;?R$UmpoB%i>QMW3cs{GEr)S<|!Q-KTGk3=1BmCbQQPi(@{;4f-qx3K^hQa)jR$J}jXG6=QyEIlgdo>#C6Xk? z!lYb1hc!5Cb$SE6fY9jzYJ?coUqx$m4}*ipXWa?tDQ5s?Y#0n6uj~NNMuM|Y@#8NC z&9aXnnep*){+j2sAFnpVa`9FHx0fi$%buD~rtJL+j9cizS@R-g#8pWS%)!zIC#PV!QH;z3JQnBOTcve zg=Hl5$w2EnncyG;fZ}Nue2vwY=T|$dXcx!4f03v33aQP<_f?M%Gz9f>i8AYJ`YuJY zK+#4wo&lkB52+hrR(mD9m`wIK@VWipXXH*bSYm){(xc9MRA0yO?|mEAwRZ!Ik{qAp z!8yxKOP^WP5`eG?YT|>{ys|SAHZul-k%QCY{?YpNXL?0N`1$Ub@%OoB9z%A_D}yG>+M1V~>p3LYxsF5}PqR*0yvoi-j=2bb@JS1A&eF~W#Fak_ zD6)h@p0jz8^^a?%1f1p=C*>!J9`r9)!p4{kN#xjKAjl+PyvY#>@O!wTpeVRtV`J_lN-x$;aDv*cj0TlTv0P}Qq+Qf=% z5~v6LU|G~ZG9si?_wOb7are5~C;1~fe~fE`u#~GEHYU}W9X~d1v^_y!L1#y^xgJBF z!|5+=j=!m(7n=U)_8?YPxL{{_dRR(rJB3{#lE5W@O4r&l;aQt_vbEU- z@Iv_}y<@_gB|BksD{yGN&m1m-Ip51-M76A6+xDH%jrKlACxLp~_b$5t9m%n@0tN{BLoh846NgIsfRSErDZ@}<0@M)fB#PJiCy+q?;!)e4V^o5h1kKl9!BBR%8Q zlkxh1FxbqrdcbXWYQpM>cmMjFMa4>G@3kDegWywFGR<>t^4z1kBQ`d6W8r;cKa^rC zdu|4eP`+^CISH`A<^m+&zn|caf}D_HBKO*h@Xr1*IgkLlmESDYz*4_j5PX`CuZY*k zGNfqtm99dS=yoAGUoo@6Nv4!M>Sr!E1F>jr4l>xO3T)Ls_A`3;oq6Hh%_aZ~nePG` zBf@Um@I+JngH4Izt_sL@*JC>&yTRSjdv1sbd?=~XaNG+fEc5h~5783?L<15(25+oA z37V3GpW|N^uSgXX!Le;|Wxe#N?5#k>iP&KMoIJVlnuEMbJi|dY=x`a1;wKGE<@6`l ze{0erh_CgLRE%q2 zig2EajrptHe61p8+HgJKWW)i5ue`MfRqJF=A+T-)E=9_ZJilBo1^|BMm=7Gch<3qx z8`dux8hMe}aLx;TT_fRf>YpPcBmaC9aL=OO_!mrZN`oiB*WhO2drl|q;=7Z-wYlk} z8La85GIY7N7SpPar{TK@k!wKDnKqx>GnN`nw7!c`Hwec zc_K0ClRs~?sM=Cq{wpv#KG(uutA6tTx6R?bBJY}v+7}R@>{x<{ED@=$EU+rI#YrlZ z5Zz^~^*sq&O~pe~JkQ&=;6*CI-P02RuuCR?(GAi^9U{ZDvgv03tpfR0qz8}8Du;s( zuP&24UEv{dXh?{JxoX&pnCuDtgeJN)B5 zDWwiiwVu|3eiJ~E47?P~x=hNAemq_RY|wPxfBPVOlS;tFc3SK56(oJw!B=i`t2Yw? zf=)%rNCjZ2j|8MC>aPz)$klkjBdr9rK*`NiPdF2)E5y4~?Tgat^FgOUHx%zYPyW*E zP=*DnRUTX=rWSyrP<#Y{vi?+>#zttjbAZ48@2j>qvP7tQABn!*MGJ9;g=@L?X2#bS zwx?&8T&t**E0crbpLceQe3GfEHK=F=r+zvd0C`@6LZK^+Uwq|mNLF76x?sxf+r&QB z{=`9N-s1u%;a^v@tvWD*2|&E&nf-9ujV@CY`4YK}bpsddO~J+nr)goXjl&3gF4?Rv zRgy1Sv^&J`nS;jA} z-2!IqTC?Q=peo+<;U$)@Am^D$nMnR_@FRDI5C5lAh4(APeW2^iPN*ZreVN)zS2$z; z4E&>Yvsm>%wgF63-Kz0wKf?R*qyLF%?xYlgC_-I5&5&>UlR3!U>DeYSHP?Bc3S#*~ zfBzsT1Mpd<0nV2;-lDKKCU)@YSoa*1M0|5Ln3-X!fHeV^9qRIrzyI+^tB584k8gc= z^l<15XbZsU2A6N8i6mUxq-ou?tGoLzQS|*+_&0eLsIU*ie+_rGcE8HbK#aW#;JrBb zy}ZA>J0@KL;2>a=d`2=nQ*amDzs_$eN;EfrK)j$QNl}5d;z{^Q+%kUDj~_n5*UHXk z+CnZW*Eoe~^)>~Ndh)~gq6Ji{h}jffh@U~ zZ{rqKwIN8?%x3f$Ktoi%croQl2G3=(%E-t{s@J!_TWaR^K2tX{EfM7>lUA9R z$BQ)lO+oqvSd6RDz$%@)FkW}ZZ*8GJ9q{Xb6U7V9YXBT0Q7g*S)Rgyg%Y8PASd2IF zXFgx9cEqZ+A`7>UdB?#YlaT1yAtI~l48R=$fOJN4sPcybQ-kF$@-g-!LTBf@gK#&r z8qwiL=0eB%KixL1=Pcqgd^e(VciGYBBuojSxa>);|uM#?? zrKRP6r?StL?@s;y!>WN$qh7uyoW*mzojXJ9oV99X(ykx&zh;j zKT;Lg71KF?x)Qf!fxXreEjmmfWETKPR!o>8+`q*%hOu+$Cq_y+Ds0i*1`-yL)^ySNTji99x9qtT5Y2N?-{c`{)vIKyw z#~lz=`2H` zj1J%c{qYEsxjcCi6-C^y1{q->MMs~Y-KmSWD~h#rL50@2A3p}fe_Rcs4w{#p!#K20 z=%i9WFrFE(LZ;aY@Ch_9Z4_eNPah$KIinj!5;O9KCWP~YqWbmL+#N5DLppv7BN)zv&<3QkB$!T`d<0I)B7A>)N1gbs1<5WQ-p5$J_ndBFCS=N3DA z*;zz4;#?ApvEH;)z-<-|U~W;$u;0J?>d@MbR6q$W?{A$0s^r)Qw_XhJ9RJI#cK#tT zb{wQ_7LJ9hj)&-p{Kuw7|9Bji$VeUa=<^SOZYoxg)sPw{{s=03)%QzA$c5A`3uo=% z^R(PjF#U8Kz*J{a#kx9c*x4LtbicYT@_Ti<-iAq+ zeZ#R~3%Q-D|7sBgXp4CvRB4`(**y85d|mmCqlWqU-`6)v(I0`*B)5aa5DlX$1j4l+ zxu{}sa)?fh+pPkOldrF`peG0?(s=Ie%N^F>rpf_ZZ1n18qcTKSypq(@HaT>!t9K-K zbSwpzq7H3$$u~1~pF00*{>lotEuZB-<^GnNfx*&vwOlV$Jp5EzK~i(dBNY?f)vNna ztg8n{jz!+H0uc+gFa+5~+2hdu8xL{Q$pJ8sezVg9?2}>lM*s;1%NDqL`Y>t;eR}~F zdIt1bq>dbAZRLG*mg$9Z7@&!!2P<&|tRNqsWG*Pn|Myk?IE5Fc_yDNLu&Y3O-SOrT zrSQNQk$~(YveCS-t+H3IQI(h2RxfIvJdA`|i%F3J;B-h25za+&=^7XPPUD#?6u)mv zzWWa@Kl#M%+q=$JF4V+HB*gb)sEo%Oab=N2Ik^$`Rv9;35l*Rv$pMT?;Hf^!xmkL( zcQAq;xjqV-^N%6%*_&Qf)JWA13eJF)B+h$gyX^nP5?Lmy+N{@u1T|}?IRpc0-E;cM|2uCP;F@`4Y7qxdfZ9*YKOU zy3GSJU2Sk=rZ~0}!AtGr3-zrMMDzm|%CMGo1gLcuLZbvbh%$Bf{d}!EXFxPqFFrp# zeFvaqYJ;T8Pt&fvfoSV%=5CD@V5;WzYY?j3S-u{$c`FTiP6*LII0(!zk*K;GUvX1LkD%2 z7rU|#^M#%VwSV)FkSr*OV!yw+jX$W8sUDEDzwY)AWA*?r+s!>{dbCI2hbDNcU+H)C zh6(W4EDKzlqkx`E6Bie+0HiTGTfp{xp9bWSwptE6#rNdd<$B|h5c#tnI_tC9iN@Ml zaa8sMv~A(#w{&1s^9Z`>xC1~gMm|OzLb4|Ujs{-234Pkdk^{H_q2BNaCOT0!7C7Rj#u0KXvZ$z!E35$qu+`( z6`$kjHmha_M=gizc~WG)_T`V?ZbW0G_1KYiS-0c*D+^{Cxf`OOHX=lg1S)Jr1;Xu2 z+&Kg+eolNv|FK4m!v#s=@zj=$L>V>MXV%2Nu&E=aI?pI^fC;Z-hlmGf@$c@4>G>ZU z7USyX7VrjyqU8-2Da(ZccINUz0a%U9Ss=1?NB9*W@`FPo_(Q;K97<$vEzKw|5)o1r zeKLSP$jl?{?GC<>K`(C_BlNz%JVq4NPG7ePtg612)3@neD$jX}1lIjRrj-flIe?A# zkK65O3k13bfPHFVP*qPLLtHCVhTvVvcI~dd`*D)onoDEeynF zaj3W!nUpmD5ru+e0Z#iwUS7RUYNS>vxM|tb@#_u|7HlyJVdK2*5SF@Qw}DpAc^+>nHmsN0zfWHJxix9!LIwr+G4#l^clj-^L3l1+gC z_)^ro#{~f)X+@{*-*^-aL$zR;3)c8lYESIKH*y;B_i)*skBmK z1KVJ0%4CN%Fh82tf%nrWG(Yd;U<=&9x)O832Umz(bOtX1xpVY33Kq3AS+Am=r2!Tm z+`Y)Q60fJGmfDHOgvOgjjn*O3~4>)n7e{!=w3ecnWgU)b`tEKPZ;QOsGWwk80ZHkwk8B562|X= za!(pSk46CVjQv~Jsc*tp>d}O6Rr0&s050rq;KJHzZQ68Qgt3aK5{Y90Zq6;1hJZKPuv@Qa` z$B#3?0dtqgB=Aa;2U5(hLTwG-p1s6tSRd;#T|xlaEx<>~Ve}1F0~bf10lzAKMQ%c8 zYp4Kt8m$LzpbFasI5XYHnh$3whr4c$PdNNyZ$d(-dyPKbsRCv;fa7&BYUb*YG{$)w zB;ew9>&O7Y>vY3Rn;p^GIb#Pc?044L^BlQi|L&ciK-%#&(D!_kww*hvYCe@+yBX9AD&jl2PFBB*2nUF2qx1qQ<$Jjtn~uxx-4aM7lOg}*;!6P!gckS+Pfc+Z5g*b9qM z>f8yv+Ev2F7=}t1<_4>AqmD_6x1*32$+PvZ9fJt~jr%A=Hmx)4wwBWChZj;kvy@Cw z*+Af)KU8^M-_izCdpz@$xr?@Tk9Xkaa=iNEzt4+9Ev5V+r22ZQkOJSGnG$9eaM9zbDO6!32?^FW>*1CaWiJ5v>Q_{#k+ zL7(+S0C3IAhM$#|PD^cG?U1YA2GsGdaX`))7?2yu0<7k|5={B{dPvn(XoO)`k3w7D zR$NR>T<361Qzrp zMY3~+$S|m}cpFLP58w}p*7zVNJnnREU}o{#5skb&zkW&$CStvBIjYQO*rJzX%6B`{?-xDZrh%TloPY&4A<4jqyZRegsx6aJc&EWC z#07NBc#4PqXIGl<7{|)UVmp2|U#_V*`4i9t zWu*}n@`e>vNC3ga6<-XRsDYt`{4ntVkqZ2(4Ls%x$n-4DG^ZqKHa-tyxxD*<_-8(p zqm;zzwS~pS-Hj;wJiH2H%#qCtHxw#OA&yuESyh4j-XdK%_%<99{R8}_tZGqfwS$+4} zTm%!$q2EtaQ&X<1Y)EFA7j=M;K33H1vK_TlU_DsqJ~F?mNw~l@*yrtASzN;}o7B*XvpsY5SHii8?U5uVSpVC*PD#zP z%S}lzvSrX^tmtm_f_!z@?nY&vStZD<%2Iufg3z*5W7E395`lbbil>QSb!IwQ)yE=} z@Q(PF{z-Q!;*&>MkK>Q^S*k=2^KD$hgxaz40@)u2=RU;9elPI4^b$Eld8rd>kGt8$ zb!kkJOQh^u@qSnoDpt$0s)IYgY;iKC5YoU2W=7rB?o1`c>}XQ%dLEGl{ZcFEhRc}> ziAH4^2c*_uVFbYFheYmF#~c2J8-UM%_gPb+G^4L|t-YpJExFI%y6jEI6%Pb{?a6zM^YcvzUdk2`nFUXmQ?u zr3GVn5)Mnh^0RawpDe6P#NxfNCk|`fj%8thn_x#HRy{;DN!fUYP%tKKKjJa?u6IwN zcC&+Laj0XN+cwm6NKqA`WlxB=CVjw?mH0BuDAdeC&seXw^muesRj$Rv{X0T90&Vb8 zp^3!Az{#upR_$KRQ=y@ufP$~58#`satwY(fY;0}o({l*HM?)ILHVkLr%DJFbm7;OE z4JjKz4cX95<2^V&ug$LK_4<`19f#Aj!k|T0RakuMNDM8h5}Di&Fj>sa`Xdt=}t-a@85UMY06Ul^@`L%{cqTc=VkqX_kixeaaKoM z+CZYPW{9gER#>ASNdQ$+B+2eC7AWY^0o<{GW=Y~Fi5(JPO`^Khlg*P{_ihIgzuE2n zZR|-PNB#>%1ewBVf{0sAN%URpypgJT3S=jb6@64bHXyzaknIhsCn;9*B%l!dOvy-- zOc*IkRerHl>dM%Wbz~Dqr?{+)l`1$g)EK*x$8JmF*yesB$%wtX`_auA+@32G^RVI9 z0RMw|7}X33|A~mucgn2Vbz|@|VnH2kc{w z&}5>-c-iQPxG(@fxY9XQ-mHcX5DIXlBo}+kE#R&<_A_6Io zPJ~q;ElqQQL~UqWfq0Kg_ z2o~~<$F;*y<8}V^Hz@sKWKDH8HHFuLO-CD*W2B+g;N$42&O9Z+(Wsu=N@2Y9!>z44 zqS|!XYbVizdVa%tu;}}($z<{@^bQ)@W#UjyyKUV5_t!!jtb|JvTCxu$kX|J~R0uaO zv_fH4gbM@}q?0+Sht>P~c2wp8XrBT+R|MPlEs@HOyMPv$NE+|95*QoV2pHz96+=7S zxNVNWb!z2hUCzrEmy#;bVQIb(s;2`GU?LsY`pz-!g4!Nbj^bJ!aqTKXNjrV+e~V6O zz=#pnoQ(^h12b?{a18giCts#QkBK4LZQqiUZ6!-UL_#kam@c>+VH@?_7u@$CBAXw* zzB~vwP3SE1@nEgiY7Xh=13I5EGK=UbYZN`T@D)o+pS%b43sb0a?au)D8QUZbtV@v;=2LZ?9!v zzKDN`ISAQ`dh#qZAYxPrle!(mi$@gv(8&4+`u=nCYcbz3wW3S}y2&!BE&k>^rm+y$ zdCa<7`wzuO>WX3nI6!)yvg?vEO{7v*Ja%W=s3SL&s()HEp6HN}2SN;F03i(!v@wW^ zV_-@x`h^?)$M-!qP#zk2WM^uRg$&zn&~!HATU>eEycmL0=k9bPG+qT4D2{wjh55gnO@Ss* zuwDX_Lxz1Tx>wcJMekWBD;B1owa?XWtEnYQ{rE01S>7jwQ26T z8Nvoq_6<}yg>tdzd+sFrPsXY{=qcNjwN@Y8t|pEAc>JVtrcU#%^mim9Owpj!$V2aSptmR9Sv4y|{KT1;dP$3q#97tOt8J|VZkA}aie^n@R& zIx;p;2bftm6YLt?Vz!?3Xw(TF3T)W7jsx!lG4a1zT7GZ>sz&+K;g;&KYLG(*uQfoU z^tM4Dk)6$~;x1a~xlXNKP3Qxyx-JNWb`zDi{fR4Iv0kgi(!>VmhHHz;uDBSuVEcl4_xwXSpSONy1I?WnfK7fYm^32}9j&(bJ}b^sCId#wjv zd8|e_umbEl(Lgcy8@7SY4q0kb;}d&Pm^VPHjBqpT8;r`2 zn`Vm!Pi@JFle!G3jwuX5zGJYP69 zGZU7Okbp#-7Kesz9+7qyAf~DNk8LlnDng+EvpXslqgqZ!l=&=;RAoYiACMH>NCS&i zXavWGb4=(JqjUl7w}mFj(2CVIPL8e-22!_W{b zq#d1u*wbv5gRd})Rq1RMx0liGW=r?)uX_9VgkKIEcXfbpeS3TzG!^mA((;+dS<6Gla7>k z(}#wJmi8}aE5-cI;#TjOEbrVW1!LQ7@8w^`xAvBAsKE^y%fYrVoq~pIhQ+(WN4Dvo zgpe-y*(;Sza2KihAWKg<>xN#j%w$Ybw`E?la(py`I$m#@3$CYiVkXZn)I-2*0B=F1 z)FXlfT&6EUNRIZ6E2xD}pwk+D8}k*8$$=dR%D@nqaJ@{M z?tHl#NOJI^-$%8V4^KsEB+AFvwz6I-xrus~-sx~MUcoA#d{tW}?XO8^+qZAu@X7cm z_U`N$Xb(7d$71~Qlhk3sG{EIJ1ktf+=~p>079A)w6UKsxN$xa_k{E_q>Z05#2$Lh& zPF1(L1IEnJ7GNDv~f4g|h_F;mvdf?N|H)CTjy6RJUg6yGps!h?-9RFVf(x~5t!5T`gj@%tT0+{#bqz)fBQiRr*w&!8 zz5Ve74hfxcm|)K0pmMp1C}?))iY1U!+YbI828w#n90lqbdmRVJZpO?~0MSJK1Upbh z#s-ham$;?ugV)zV%jEeG0RSMDc`~6k2E|Cj6nINKW*R1*vhDO|QOlEm*yVjbMP4O6 z`(c;M_8u!_LG$j{G^FzPUv>Z|*jlO%C>8%Ev!FnVG^=*}+UsrpEQZ!; zPsi29j#$QO$(?MAhoCJHqI%hEr=z<(QorlBcg#P8>faWnu9NnLGK3u2Xb^@y_`O=9 zZ}Q0##btGV_p}}D09??2dRU`=K7AKxiyWQm>SDKa(& z?el{Q>w`3R>VWF2Bvm268@ds$ta3apu#sJFBsE=t)pM=h=7+JBE7~R|Cgq~xU9Dia zKR_9Zm~Pvr(k>@@eV53knVq}5V7);;2t&(KaXis=Zs;RMHeT#77neaSOQ9iR3enG+ zD_5CF#}Bl!(cp8|Jcz7z{lq}xCFR$r`v?kQL-Esz>h5WG^%3aUBMN9tT7X{~PSxy) z)cZfA|70B-$}1N;@yuswy1pAT?UHD?oA^*2tX8HX!o7jH^rY5HQP0*xPe)jd`aW7L z4|UQ^cpns)SF@4?Q7Ep($TiDI$zU2{IT$T-5+)GFW~p-B@3Ua(m7;YLr=cCog7Qx`#!>u(ldt#L zKCXAGZ|aVPvOd~n;voZ%$1|T@3ggeTLtroGe-q7OyPz?@uMOR~KXn3kl z0ZXAtor$!@Sp`Ex1#1A3h&PX^uYYslsMt3?I{Lv*1~PBVfZG(a$rrcJ*V~1HMBcs? z9Z?3^K^^5`sFtN`_${jNN>*0ZwjKpzpDFwW&(Hl2Zh?C@$MV~`N3Lgl+|IdE=> zs^}D#QKrNOeAjaLY5RRW6JN>oCI!frdQ6XI3}cJfSN7%ThWO6-NMB$kMIusOP;@HJ zSjcnVbCxbvSf51|9!~fDfOM1`p=n@OT8c1ZDbbI2t?ocg^O-}NT%PRL(jN)FCX=);4?LoLy^U8=zO613k0u~4W0Zx(TSu;S`W55Dl zaRt5JHkHNDsP_Hp41qAuS&KB29+5GHosb=n`hIpqxefuM6I$eRZD-qlK(t105s%Sp z;fo6Ke6r0kaM=u;MBd@|KqP^uY`-Am6O`QNumU2H00WG774M?-djCa6*mS;DW=5NbsPuU-GyXcIep+Uk3hw~Ff zX`rL>rkto4%TsZ7YH(lG%gf8nGrmwOS{=3Y01<=Of0!nhxU5jYKaV?jY+}J18le6Q zCktrkK}1*j!CPE*XhiZL%EpT>9Qg4F&Qvf8sI8Yx*AWOGhp`3En7nJsI}IT||#GqxG(ltvg>gqjI=5n(E^=J5X7g zN#l-DWc3;d6hv~f@@+dGky37{tdteXm}V7Cdr#blm1OgiOnWB*i0RQ8xU@4CJDigBF=$VKT9UPcpD z*NSZUyd^-*Ka}o5nih(Lz_}DIDakyYu9P?JI`knNEy?n$id*`|JmM(`+(d{rV%qua zj{x-NX8aKHtVazwXbO*|A8?3`)e5MQ>hO^>+Myti>VC%oE0XFRxQ z&8ljAngzUC9Nz4Or*YV5864|j^9xeApt?|Q163^codfN*xw4RTYSSJ@8Jte5}c zmdody1pR8$ta(Y2FT{e33G!VYli&IqYMzRE+{JdD7$l0*PB~wZ&V#6e_2ZiTOTgP4 zZ^k_G>x&oPrn`e_1UKPcjaI$gpV&EQ@t+h_FEmdoi;=LsWm=GgdI+7cnko^?Se1 zEyU&C_BLy+J9gZDY_;r$kI!nrE?&9S$vlNQ>eZ0LHGJph8Xt=oA`67n4S219Jr%IT z=cB6pqN(ODBqYP8Vt);+59fr<-IjLvS$Ou{l!nA?H&g~0llA!Bxkr$CeEKmsijc|q zvK)J=&)(|BVAfvwr<9T0Q%s~+5 zE}zIfizA>t&@XG=`Sgv+JNiwq+oH%3t2}tLI%<-2~csCWdrPfa_jCH!gT-SFMW#{`Y zES&-Mc!B~|gFz_D>|1P2@UR%$RNuD2_xb{#zNtvmyjh*bw6YT2h8cbmp2sRo7sgU zH0WwR+~?%q_FqJ$aC|;NqD11yp72EcJbkaFi+- zs?YrZ1N3ExEk{&lyZhGNd2>&OWIs`~funD_41v4Ru;qcxT9B|Vwvnr_b{QZn6BI$p z{v__nA1fgHm&mTvbKg7cMi*PVAu0Zu znfa_s9%?d8Fx&ugbipQYg-bHob4l(XI9!%`;i!DjoxU#mnN>do$&-fI_9=JwE4Qle zqx@jy-dXJZML>VrTyejC;<43!e}Q`z z$+yO&&^wK!HMVEoMy%+@HGK!x_wc!pjz~qkJZ9O^mRu|uW?AMj(+j_!&Tc&*&{i7_ zou_MfE@*$3!-ZEU7gmMbWXP0E$<9{n#Irv|eDdgfRYkk}+fKx@*d_2^gGvaJ0T#)c zGvH|QFc%X^vH+;?f&ry%7+F5WB_-4ur`-)NFI1s)&my*O z<68PL@6!_Eg1-laIrUrh`X&xU++9JA-Y&rkC!^W*-3`k884y<2&0v4lBy*NGs@`x; zXL33;yP9{8f19XVDa4yBsF=K~6x9~5W(oR;!}@LG#V8!+cr=`yNTpYgMlcyv2N-Zi zfZf7S$3<$TlEb8qqJ(PSzU5YOJ>U#VX#O@R{U-y1gc^YmSG#WjHy6sry3Gx1(a|B9 z#h_z=SiJ#h3CWMDp-;GYD(05Sq1s=$CP=iEY2{o$6>*zB3BAdo=6FkFLtH8~4OLOE zLMZqMq7ivPBzm-FShD6(csLefDF;wC_r54cVbl6N>+H|Gp1R8lqKQ>nJ3bEq@_U5* zx=>9kAbjN8jBDUN2O+}=qp=Tr3yX+7$->tFz8lLZtxOQsCyH#_TVlJDL(9gkP>wEI zrd8qmN^@%vHF$bST+1KP9n3jQ28oqFA^9$3nC$b%r~U(=#6uk79#Dj>U0q)$sv<-! zNj60@zWLh+1bR22%#7@?R^{+TPdNlMkVf*;1TafRlGpL8Zb8HqF((KcaX>$5&%JPxMsS}ob&SJ~;fy~aA1Nty&>JOel4hR~nT zp8h>Y`R98b;JWh8TA7+2g>g4r0)ljS#nXkIs>7kd|M~=B$%hbK%d`vN11`10%DDkS zuZBbgJ_K2SuFZ2WMCx(7Nc6Y^goohBH-l7CuTCJwlJrlHP%|KA)qvmb*cTfc`=BLF zgF-{L?z#9GWkNgv722B8$~DY*ItZIx_q~RUm%fJi)hF!>OG~ib&&taBT`aY?!Ox8?R6a zm4(9AZ5OQES<4N^_m;wjM>FPIG$`G`duV2~&7GqdY%Anzn^-t7eSU{TvqN7) zylfJHfdrrFqSEZc&$c-dzBYNx*=^fXc$UQy@z*YjNi8WM~I9l znCIOR@6KxRQ?RpQ>sO2)bG=L1{XqVIow0e^qjEH^S>s;)W3$mie+Tg_?nc6cZG@=SRaZb?onR(LamG zA*Up+$Zp)VwCm#D*o7O@4z=&h{aKWX)ji#QZe|Ktz}pgLjHDb*M~=^q5y)7nzg1jb zi2iX$zuyU78!t z^_p*+sCjt(mu)W~ff^zXL$^{IvQhnusuWdRHc@iN+2*3QVK{f^BJ1kvPTS>SO5UuG z7n^I#Sga;B)E^3}yg@A09LtTvO(|kJY=+m*NlO=<2s3P7!S5xRO^dY&FU&?3Uvim4@C85;s4LUI86Cv5M~oiE~%dDU%#F{!Xh z+}`hgc(r%Q*|F+s`9V-i@$?bCw5f*H4v&dJlqG+36xNG4dh>8N=*Pw=C3Py2wZ zO+n(DlSbmUyX1_fV3v%We1QA$QYAWx6BJMxZIhFe`|Kcd{ogMAcd7uanODUtFl=T> zM$LLOmKIHY`3Dk);&sc}kl~M&PR^{832_B9;HmF*K$-b+B6#+&0+yd8DTR%AFW{4l z=aC4oq#V~d$C-NLVs>%JS%uO=Nd$(ESLb}UL*>P+LoRr0QA4PEzD`&%GcFj8UiTK= z6XgsNhcZw|2-_3u>#vj<6X7Ixt1L$5Ek@j804OzD>138N#mAvFLPI{rr0?$0GNRQ$ zJBG2^7Y1IZetokI)JMwI<&no+D5T3T=7wDX0Mug>C*{!4VjbR~QMx=Z;B<*9WernB zllkPV$EZr~mZFyIa3p+fewG6o{~-B9mVYM!IRnZ7lIfBOSM(V6DQD;q zctQ0meXR(UOJ+u@y5UG8b|ax2jdXF-SgpY#AYI_l zL4N=}Xu?E&dNvx3ewubEfHkY*M99U2aUyUefzO{m-&sh@(iU|-P3nexMX zLI#(hy95Hwgv&;>ZKx%XKrq+Q>j za}K3E{G)CEjqr=;>01w#b{{lY=TVkl_;4HbR#k8FV64chlWvaZwIJAl0n zuQk)ep|@0;#iFNH^Y$J5El31dS@ul8C7{C{G|JorZuiO_aDgBgRE`IV?NoO@#jcC5 zFUp}nu2jwdDG-6&M33}WM66pxN$?TXILV1bhl;3u2s$FAJPd|eI+8g{p7E0kOCYzj zjK*^@D8!Q*@FDr$*m}bPWJs0vOe!E2Mw!^h$Q0<^dd;nmLOQT{e;IyAcGi zbZz}EqhDe9M>0qM?O(ps6hLLfef3yYmAS6V-ZK@b4=;*~XN|UTKYcZRw|1doY;5=W zyHc^W&E~01rQpFQG-CpqK!jPiL(5kJ$us~71$zPlq*L7VCO`xNi6leC)_I~&A2a9h zv#C{joAsV;sDhVxq-Z;G`L_*|{H93GF^c2HywAdaJBzsA##DKw&?A8eylx0U0wc8v za8hFu*^6@lzM~;71S`5gTf8podcZYpan9*?Xt33J$#U_=U}<| z4{S{Pe@=ZyH%vWZJA@x20|}*M4k3;X;?e+{fh1KyO@kvcu9lVgRuZ2_{^-AELaWi! z?NJY#g?#|F5%UD0HAzy4uoLkozTgpK#0@c`%7gPzlpTXvX-RCj_3GU;q=GVUvQXj6 zB!?LkL17d;R>K(ky<2*`Lm5oO=KO{(VKOLHI}0I8>lUzfp=8amR_ z(o!@?TM&w_1uWuvnNqw_on%RUx4@S@q+0-5{0j(MjRaAgjpZS$UQSNV>zmJzF8?Gh zE(pBW(t04L=kRT=BImDJ#YtFu&fOEk9E%=vY7rV3gGP2D5O@kjH@<~YarN*pq2eVI zlc19R$|?jq{PE_HHmaT|UuM?%_AZNyT;9nx{xCR0DPo{La!6Z9XN^f6$xqLYk(dYo zkHt%Mp$UW(GVJYl#m07eqLdpkvF@;TVH$&UrLsEkXz4ftA~rk??@YUf9<1P?Rp%KXCoRjFO#mr&vt_MdTbRQ6ZsF?d_e9&wXcdt&hEC`ebO;)7PPu^fl{Zx1{W+YxXWvVlk)y8u;_KP#*T=DM@BGU&Y4QvK zG9Re70`>rtQueE;?C+j4Z>uIN2H3F6@AmoZzV|2DG#J3d@HTFb?Gn>R97|otn(wa` zks$Os!|PY-(lI+Yy>_@$9-%RkhSfiGmA$3}byBNjUq1|njVv;)x7hPp-LeW&J_|qx z-XG*@Pk!)ItYW%x*oI@5V`KYm;|ISp@jUX3bFX(yc=+Kijbgaym1oApn9C03FDAGL z|H0U$Y_Q>{N@H9!_MGQPL%{d2mP!hiX8IDtW{yPGE%sfHd-}A_K@7@rxda6TL8U<| z$pX%8S~@mtk{J8s$sbVmiMY41@L*1^_DvHt^XMZ(T-@BWavB{_%I_oUN-4U%c;uXm zpraim6=MCK$eEC#pZ82kg~fPQwxR=l^(LRH^n?xk4Y{yo9vBP@8X4%Y-1EqvPy?uh5K52j`S3{ z%r+_Uk&SAr9Lg4S<<~!I_`UwLbSr=xg8R8+8k_{aPWtU70z73cBnclV`RUM6q{dfA z*7T9Jou2!zbN=KWQVcS{ka<$L982+%SJTUtWok*lF&~Fsm{+N~TYda_6~VH^W8y@) zr5h;QsxF_ID?Wbm%@fqz75feKF05}EN+zYnJBun;mK8j#jIr=+N6B>s5Ut|I@;s?ww~2+x*=xm;HXE$?Ch+_ z%rEjr;C=EX^|$m8&8hC^6%+{kylT;pAKzc6JCvXg-NXh=&dlZ5j=W2HO|j}^Q74B@ z)_*jaXT(oVtStweIj-_OA7j-Q{quMOwInd`J3haiwgv^ArA7)XXJpj&G?~~}_D8dv zdN2|86MezpRoe>*&9I9DXA!o?R7xn>N=E5#k=#LzkKPZl+SyQ1Ksap*T>YWKMSBy1J@lZeW1u8&@q z=%P|{=ctJzq%!NYgy4(E13yws{^$yf2(PQVJk^nl+>q%T+r=YZxqUMZv`^Hlk6YzA z_x?t{hCO?A1>}UAFaN90J8fB^c$gXIYl#Uk%BD*-@?%%9XHT!#_@pxn7$7g7H0})e zVcxsUv*R@>v_NZ75?0?`t|~(H1dhPV)(w^Q{Ol}9I1{4S2dIe#hCeq#jy4oQ^D%UP z4NY~ba%>ltLgKB#Ep=L3p5^?ddpP&#s_ac{bighBuzgFbYomkMb$idNyLc1q^A2a| zWe*K55g66b zAT&mfq)dE6>SA=kz)l8nPN&_F?28R+f<=oMH#Pyaw;>7!O?LWnlS9Uym!t*2FW36=d zN&A(cUz`7~t&02-uA4QT$7bydxZnLO8h!UambLqRSS>Z&ILctIc{Nprwzfx8*6EyT zh+?ZoJoDj}(Up)K|4u7i=fCZVW7k^V&doWymXJGCRUTc=(%sX@G}!XLhi=>f^5^GU z3wnCyQ)CdK!ia1ix z+y2G&$!A{fQK~86%xMa-Z$GbweC@=iuQbSCpAaRouMu3#%@v|em-Kxp@uR)PKMa5T z_>xDO>D%VCo#%e03E{`TFaK+tt|d(l1^nJ+$j|(zo*{+4|Gt(!wF@f*8h3?8CDJ9j zNZE>B)-qMB3JJLS^#XrPZ>fImwMU z4qElAn-8z{*BDK+a*eAW#bsC3#3|HLbQy~`=G!Ue`!y3{Lu=MMU-Bp%KtA^K%df45 zkpIgc+e6eZ!$=pa1vxEX*AvfY9uMFR8|n{-Hyn=W!yH3i)UUa9s4f%8jRdqVjueeD>D{C|JFRA1dhfWo?Frg8E9 zGDEc{zoqf}az2dp0_y&dF%yRF)u%GdjE)>5_P@Aby#Wtno@rF{6n8uz zbO`Uhc2gkFMKdNLFgHml>4|`*oU??9a3s$qD@nKwHYa6#UBU>+d)IRq?GbK#Ffso1fi_nJ}>Be_uIv62!8@Sqs8tesTu5wF`-$zCMRzVe~`b|M@%S zl-dex8K(SjzY-quZE^3c+sRTR)S@}_RR>&FHgmi_t8Cd~{Ra-ud*`}O8OR&21YvpN zQz}PKi(a<)_1FuY2SbhF7`r(d$DBc|`2S%~stu z2CU#fy9qFHilq$>pmB0 z)#z@rJD*h011{2*i zBgIjsD?CnoT2FaIUroB88)U{Ta|f^f|HD??vYjT)rnsfd8YjqQMqGShHN9obnF(gy zvPLJZr{bi{AEtUZ2#SkuY#7t`TZZ411a@-1dkXWk_X%;LD`gK21H=CFb;9lgcypwS z01Cy)vt_GwNH(*{|FO7+nK#JtWUyeL60;Jp!n@)n9$gAk@|w}P>Ni-_;j-At9D7Bj zv&_FY{bBlh)D~M<$hKb3;EAuS)R$hO>KhTxk#)s!A3yPHW(wp|iWvW@3Y9z1R&;mc z@eXZ$JWskr2>1bHHis(~R=>VOV4^8@xo`wnf{1sxsMx+;iDgnVSC9e^j3w{^Y+ZaqI7(=c$+)v$h4tiY@has}_H zJ|fl3Up1~>Py~ej*ZDe$-txqp($`I^)|S?nFl(Po*1cTqZxBj!V_zywwMNjE^%$SA zOcP~F5#GG@nA0AdXFU!5sc*=~M z_-Ac7M*T(FG`eS-CUC=pl?pUJa7#T%r&w+?i&ui(+iEM>ZZ=L-KI;zg6AS^gR?yb9 zKQ{cNfC~F5mB{fBAt^5A=;uUjS<|0?)WsPEHT-!o)3|g$-+Eh^S)+>i!WwwLt$qvX zoY)m%O+Z6*F+V}(2;^)1FOx8>4Qm^Ymbux1M8pXfWS|Pc${${32) zqG%N7`7K5X^BnKd7szJfd)&LWwy7NB^v=L}bXI64zQ$(IrVq~K_wPS4DL>TboGlaE zFOK(HSDe??Ghb-FH`;4ZL#fej@#2wnmH?34#(&+v%2mgL^5UHM!FyUKnGej%Oz&m> z6Zr#^nu1+&FdrVcH(HA2myyCLS-s$G1K$=O!x;BS(s8&vHQJ=i>vMYJ%|C3t?bM5p zhV%|SJpe9hCMoHhTm^sO7642bIi!R(T;K8*Ktx zbNtI5u5j1-ht2d)8!(5O9JJ@GWg2i6LfIzpOsa!}U*;rU-9me{cGI_^5|70sFfEM# zRQ5mMk}V68d4p$~|4)9l8Rt|euYSf80>g~4B7j8Sh#A#U7U>QDF`n+P{{f9}IkNe0 z(bj0C=V4{Y1O??THndq1T#myzvR7A+iuX%9n8LjA4xTMH9{j{UFNV~Xq4%#cdA|51 zhs%_ag^kx`ZF!xodAwo4i-OnRmkq<(EqzGD_j=0VY_J|@0i1#RcXoYV??tQS`}ctn zw65iY8ke0i52b9lC#a6GfBUHG{)&K||0a5Ciloj@e*9DXbp{}$Rg*P!<_%1q-?DWO zv>s3Dbr!~vEANe-7P*Y2FFtGeNP+E%8}=S_C+A5au4jY*^Ud!3TUKB9Wrb@=P&Sda zOy|RhA%K4ao8I>Y=j-FdyaR#rCE8>~Gov(jECA+(4u4bEy>g;tu5O+hReN*#QrN&v$<@Q2b^(n&aWC)nOv3D zlDktL9c4bezmoFiQ=%eG-)E+5^z`ZXl0LkhS3HV=$=~^d(TqLT%idvfdJpKs3z!INq#( zvKjy2^_#YyHo1^!LI#xVH`Rv8_Vty=-7`D6yDb+vmEMb+guwLD!OIenJ`+u9UiM^H z-LNL6Hf}QU*QUfadEynKZQyPJI}^`MN?NFz;BH5dI~)~J^gK;jzjJJjoC;1GkK5t5 z#W0j87Uw)9E#ifnG+Oj&-GmT)ftVt;$jyV;0jYd4vq&=v1&+4PB3Rp(pX+Qe#QB1o zcX~y3t~brun8U>HB*I^NF%*F=?#oGA?~wvlXrk{ypmrM5>+qk_*gQ`;Nq~A*rrhPH zPm2t><;Y8oy1w}N^cANUyeY|C_XIwYz_u6%wQU#|&V8FLlRuB$0;JNfHL+1=S+MhB zQkfwN^r#N1$-3@l?;543{Xc+-DT}bibV!wU8r6xxN4Gb&AYIcCrws%wVXjX{=rR=Dh3+dr5ka~_#qBX9z9xt1By4~! z=GP#9d;z+Q^le<7KT+G-+SdAMbmd0^ui9SS9qr-R;BA>@bpCz|{3GWV-U~mP)7>+( zvMNo_bkxx=KmYjy+j9#J%%P2UsBNAj#g$v;)%fHIdhUFkKe-o1|KrOZvslir(nX{(eoF2rxe|>Jn?o3X-SE#1ejiyA z*>>ISIMwN!m7QJn{{3bDtsggeM9O5PrBswrpV4Gw5IooF8Y!5T*zO77J5GDJI z7iR!*1|icylQ9W|)aJ;8=cSl7;6j$RjXgxJVY%B?An|I<>2^S9F7m!1i zc`Kj2YTy>`r;sLgmc(s+kl^e#VJt^2hv@sRZjPjdnlP!%U$DcT@HBIugIDw#6w{eG zAE4Gm5C6*h{mjzpf5cgmlDchsvw=F&Xt6XY<3gUq10Zrj&7IED_E4U%<^(y-c-bcb z7LnF?=I!_swR!jMI(wS+S<=>sP13PFb^7vHc|HQ;?#vZjTkw3D4GeEjjswDRl6=M1 z-*LvtZlE?t!ao_%8>bH?_03eDff$6TM+*`{VcG!XYyGFJ05y%Chltd_QDiNi({Y+m z?>0CQeOO#DBfZ0VcrvDr893E5s2Q;zDfyca@#A)IG*k=`s zn)VX*CN1Kc*9otko*UF}XfrrdFUF?cm6bvQLS*~uyB}B+p*HwV%u{5|l9Ke&b^e~s zjX5wChW!PHQlAgM>;>3hbC*W%zCC)Lf7Q416ZMs=rTpEktDX6-is@g#R2&qnhwTaW zi0r|oRCFU0RIv~!>kncY1UZ?1V9NibCmbdWYVVMk{=oXe!lPr#J&80?Y3k@>OC z;G^Q_V!n`hC{C41%9TE^Wz-V}RpLn41eH5L=KEN`wPgHV) zG(pM6ZwAYp+iIad(a~#z^ci}FroEQG(y9pAvlIGSZzf`oU)#0nB!+!AoM?Y zHGY0+Pk|~8+!YW%JXssr194_cG~IP$^zC*3!6zSP{%+%s{Iafa{6rhAV$zxQ?maRw}zu)Vh)i~yQ9#8mx*(tXTG{mh5=mI~ph|kDf zlfo$ic{|xLi<#=@Tg&5=b-lp6L;tL=8g%$UzOsUy5|B&4e?mkl2?qR)#mp4GEo$R& z?j!ZM|GqHafj5z&x88>-orBLOv;;0L&_*TW&gv(svGN3(WgUtj5+2T+ye6?>-Z-~# z@1Kv)x438CD0~>k!62J3QF4}qBv0F2r58FlrK$HHyIm&Cncro4VKWM>@YOGJmfmH> zg;2yIqKa7G(#1t6!;b_b9$g-;u#KDufhgmZh0jewH(kWVX$cq8*_oeH;kelu@?UL~ zS-Sm(LNOnvDAeP8+SKI^gO>}pydQs1kRQ4R+@NDqr!x#V9wkA`cBcX({?Q z2rE`xupKek-jkljOBi~Q5G3_OOoy;rMe>w(%h!XhziCS-h!6<|5;$;&C){Apj5CyY z!)`9RL#5iccQ+o+mT&XU?T-fP(|m0VheV}rdCMebC{Wt~?j#0lmGJ_X$Q(%jsVke; z!J5`B#uT(ADLViS$wpivHYyzgq+a}*5e4t_OYgC~40o)GH;8Pn-k1?fL=^V_R0MbP zCt+1e{#s7}7^PUn~d;7`u@2jf)p zH%3FD!amH=s0a=&Uy0B!@sW7h{;5Ltk*SGzCg3%fq{ZhVD2nkp2%tkUi2@PQn!D#7T~d1S5F%p}#9^O} zIMv9f!^2}^AS&8;B1f^!{x+H&iFa`vn#&1BDCtB)Ifm|DnK3c~0A5bZ?uX-%%Vbqi zo^-+G#DgzA@KBg7F*EpxVO`#YCR#szH0v}4*q1;EvMqQchG-`K!O-VHO#{AYt`LmI z^GOjL^O-6rRO?NckL~{=?7IV+JiE7PeU(8DS4GtPpk*ASC(D6N1wI`o7=uhs2Egxu1KS`;6;c zrxKhi2xhd&+$x$zide9KsFD8j-3Pdst5u*19-hR%L3cRK310XY|1mJ6fKh;Aq14 z^_pJ(n(U`l>#HK0sMicgfdeKuOll~~EvJXtT-@``Ex z18<9jR{DI=GRT~*){%G$6K$98{Ow=BkF4_gl<6X@OabIRmX;U4fH>Y?yi@@b#shiumaMe)6<~BKd6`#(#x# z92Cacs{s_}pW58hA|jeD2XAE>NtG7G%@lwWz^XcWN0i20O^^PghO`E)5~~<)_%&lW z&LS)H>cuy4k#ikRc4^iKAe*N32x3f9TiOKY@Sy|BSmz|y#Rcc>|$Ybm?0HUQ3 zor8ACr`+BRn?V2Mi-jWw2}vb*BTMUy?1p%J*7A8SXlXV2?J3&71HVCZr5IB-pf<~7 z@!q7nNd}hLkh1vh*7N$hN|r$RauNxT@|pjw#fOMMFjYi{wZOg|Lrx1m6yCafeJm6@ zL#_(lzoF~G(!s@jNKsb!>+bR?MNB=&kuzLn)cJXpLR@5cIOVLQmcNI)nsG;&0>Z`% zm#j0#oeb=RDg62Ad}+?+ zjCdIx2&vQ2Q`N$4zhlumoh5X&AzrqEWZ(&@`~QTeb5i3PunpuIncm2P4C9ot{LK_! zoEF3;%8G9zNHWbQuu5#u2oCP+n9O^uUTvoE(bl^LshyQd%is$THS z&#eA|N{F5F>e} zorT13hRpkFi0fm&xb6G>kF7PmFPF^|+0#~0-xk*V_xRRW0EB%R1Te7=g_>-brFEbT zEX5Z@aYb2f*Huuf^(4Yk(desR9MzHlL5>@QWcUUJu#F-hqq%Z7*8&pV_?^&_4vnQ& zRZp(xQ$f@5RulW~o|g`Pdg6}Ho$;IZd(Nbn?V}vo*WmMc;>bOoi1(@ybK@5;G_dac zYUUcj&vm}IPFd~`bEXbJB@g|$Ik2=k^5;`NyY*$YYEbvW zZPUXqwr0gM)aZ&AWBo3j+*K)=Yo#XEKlNz%(X7RbrzxKYvH}SlQ1S8t`O$;qv9-F| zpp14(FD+p1-K{-s&dWnmk+Rw->ff?8qd-tGSf$|9mM!V^+tzcxG<4hLa3Y>3B+O6} zp>|t+*{J55W$`}5e!Cm@)Al#3SLO1XIjR50qXNQU)jq7?Z!7heGs?D#(hb@<#G)1% z^wq>hAgaQLa+-NAj*IGU?_J%txf!ncmRm}bOr2w0-7^C1BXvZ&&EPrLeCxul61q0c zwHgP7Ch_r|r`!oY1D;T6wkWym<&GW_$+72E!lId6(eQmf)t|xaIvGRZK2xFks_jwn z40vFkr5OZH^sy30S_G08CSo1?9+V9)-H};3x4vNyW#zoe*|J*79mdpuI=8+KY{sV2 z1HwJQc+^0ad>cVAK3gud$@vS=3r6X66SCXRsm{H-vnM)6C)bGUWd6tP^E{}|`2zg5 z3rpr#$qfWCypdd7171-!ZE1dszDlvgjkM_MY#FxMms-;EpaDo&M6QbZd;c;)tK{v} z4~L$m4K5b3&pVg1)mirf>p#TS9Pk(uFbJH%F^#sqC=gWh*j>ZPEJqsrvV1xtFS%D? z&MK`#MS~*jc@-24a!d9O3^0e{RD=Nl3(M|+*OZOslg$fHp2!;oj~{)pL0p`>Ik&GK zW${**!zt9>f)-IlYD9YWj^JH23_eYN+g&$eM3;{fp7|rIHfe~CQg50K4!A|tsVAJkooypHj;JStBiLtv%L+`r@}trToq$I?%OkdPO0`1ltI>`ZJ>CdU$8C7v!-Se2%%kt-X9wwgg z4sP(9>%Wb8V}g57?n-NITl)M|qYgP_>p1u;0&Y#qfn_u%HL+cfI@L057{e-27pTPF(@#Q@ zTal~%r@wW+8O3Hg*40ED*|vzD0^*48%OZBf8vz zP2Or(Uzdf8+u>Hc{Va{jkZURhjWvQgQef6}xp0@UWLmB*RaaK~$>x&a4}5$&PN-ep zBuTCxC!}t5jMvo;vh}*yRJM3QJRp$qMzh017YDY#l}I{GneLl6yH6LpS#xa-vPhSE zQDJoZe{URDSr9*~NQevV;w;z)dQ`)g`&i`_K;})IdMJelWRLL&|X-Ox8N?3jb zi)f1`ou8K1d%Mz-*S9cI;S(#sH*1TR)@jd~)wRTjI((T}-}pYpFeTq%*IN7gRT5me zTPV3|o8)ZV=2B1)&|tlEDqVH)OZxj*bx57mkdx%yq?vtoWh!bs>g@9b0Hh2K?&YU~ z*xUjnA(;443R<#(I^c`c*U`%6?1fcZxy$T{(JAlj5xeWlzjf6svCr#ZJb!J46JSn5 zWUGndWAKLp$pbPrlH1wG%peGfQWKkcoGEkoE|uC*%L@9(k&zi@ltDc@I|=N4BJ$Q& zR=T_mlZgNRWa{T%mFSg=w&JGNG104}Hs{7RconY@ZMG6*WhN8Js_)5~`{PfAHRgRU z#{EUy*I$G%ie@ItnjR!Y4;73)U`!P8R{1a`%K6_`N9+7egtXrhl|P!|N7qVeIuX{O z9yMOEIELT!#8akCK={MrKf2vs(gwNebwg=ts9=S##u5Q?MASv#*j>JIwtL6L*^O9b7tKnb19ukw;_K~S_e%%^ z4yKoHhw)Z2ZKr5vwY9Yepg@I9Q0|>f8E#WNqN&omQ>J?Z*96tkv$bYBy}ZUYg)a1K)jXf zR^G~J8IudtFs{xrPbKQq!KL9Dh7gCy@dVz+%wb!9sJ8T>zwbx zq1`FP4l3(g&F<4CV6=F->u`R2>+Avw$uIk%#A5&dd8TuUSAMNRQx-HC9^={%!2I3*m8b=le}j z^Z>l0ywJ4lOQLDR+i_REzkRcvz~6v5MSa89py}e~mQ|ityje=UOjfo?QK5^Sx?O+2 z4r#>j3xhhro|F0M<91c*iXXlZ`?*H|=mzf8l0NO68<$YW`zNe2lr z22{Jr{+9!g)&-*?h9~{?o~Vd|dI_qKV3LxWY@C?TG7yKyhVkK0VjWi5sh8qocj?k^ zu%o+UV7_7G5DBDqi!)rJm{r;m`N<&#;UX3@p(Pu3wV{l)pOg}HQFS;*#b0uUuOU2MTceo>TxCnCo2rv9__2a)ToM7|*)p2ZYd@Zapi2jaH z16&48_d@79-EdcY?b>dcze!ftAb`7$`?7Wy#P`mB;A~ZWMWxuzGAz4K=ZRUwfVfWb zD_?i+jg6Y#P-0zj}BVPjoDO7ev1LQelImi5YFo8aI{LaB4;34EwZx_wUktGO;Cv& zodX8TxPQ?Y{^bbfc9?q_&aNCJ+_wRaZSqa4?B61vZ5>0aOKf^Yb8A5_4Yn97`bf8y zmCw1}mw*BcoU0;bmD(4g*J26|hc_DX&v2EW7CeiTb#x2>?8V)x{rW4;e)#FpyP+5J z-FhRf^0r5~6YeZ_=E*g|nG-~Aqil{U>2R!j6<=jWeCFP_XT|yM_imMXciRh+gAb^R zU;sMpG9nn1C{vwWQ;(A0!$kJ_-@=8vxyvate zowbXsVyrS^Z@`N3nkgeo>Q`awYZv>xA_C1cX+wD)X} z6dYo{4U8LlD%(rWEnFw359X3<%i}uBlKg+?j_l#<|Dx$5LDBX%L&L*^Z$dY$2~r2D zHM^@2*^Xq3kx|e*}hux^PJ)P80!+07cX5d4$?Z_>0eP*o8#*gWk0Ld9& z=A)g)k~#znZj?WyP7@g&^WHE%ZHIyF9Xnggs-ez|+O&7J6<#Q&23W1nb?Azsm!>cO z+@ACDV9ApuQxFKUL%h;%-y+jC$PSb zW|fk{!KhXYwUWz?dno0`o#`@~a3t&0pL#7Q-nxg9AUGlurT0zwchyF;#SJ`uCx8ov zr|p!F3Giw~j!!K#1k>_bpmvk*r(+`}B_)P0b2ABDVb}LeBzXcg_a1)r_jfW{oslB# z1%1A%mQaeUpFDr0L%q32&kcRH<8>?0RaB-F=I3vnBrx2Z9jx^rRXWQJ*aFtL*k^R^ zXR8(_j|a9fgJo=(mv(NZ_$d2bNxVuCRSobm2-2%36xpM7yJK8Mg8dq1etj7d4tzcD z97u23lsxLxfb2u4oc0Xo0%S_R%1bfa^A{&cz^r#>FawJ34vwZ_IpeKnP6<$5>)5^c z8e3Txy!R(Q9se0x!+Y(;$+Al`DXs5gYxPHnBqrdXVnH@O!gB2_9&9d0|1Dzeg@Aq` zX1rpk=K_Dng*qTTct(T8Rzmv^UCKDSdzNL8rn_-R%PVuA2eT7vk~((Q1A9%S_7OB| zsWi+3b>E}p)n3r~j^}4s&pJ>G*5r5sZ-$@2T1Oz8e+LYGlYuNCR2poqp&jlJ9nPD% zF1BRTue9L+V+u<_7=vSDWAy^kk9dtV9yRCestO03NtX89sfGT*19J;@`p?^J=h~;` z3&{x%TqVRt8L`SLfXh6s)4KS|PO1YnjL$KZOhtxKh^0Ye?;NCw{@%QLogfgh=Mf4ilc~if( z7p>~-mjqZ#Ek!~WaTLnSyP=Os9wpa^AFLEMtTB)6$XVOT>J z6m@wvGoaS~(@Sp@P;qdc17TD0lSQ~va)J>?A$}hoY}FWR|AtR~z-YCus{WDUy&C+h zsW%%CZrK8aL}MPvJ+~v>Q$Ek1l4m`9gJz9`Wqb0P0u;`67?>G(ds?E5iDan7qYz(+Cr=$jy&$K_K>LC3Y1feIVCxo*=wbAkD0h^2sN+S(TUhRh)l*?ae1wSt zRWZc?SN&0pop&v_?PFk5Y4GL8le26bTknOA-lLmpX7xI0iUH3Kb5$jbQwqA-r-|6f~@(2zNzKr&RS@Q#yX9EU~)mqM>Bu@MT{60d> z&1-^+SaXb~bYjXOg)Nu`b0nXYC}Y}_lqm8bQ7lV4_@eyD!7ciXSeaA;9z80PRw{e& zmlBPg+;02a1s}adltiDQKhhX*wYRr#)SKTV{m&2uj<6@gWCT$Hal0J$MWdS%5p&sPn z`PGxonrSt*K0_4PZ*emrA)!FgbaVA+bDDwjhSvArAKE0Qw&Zz%6Q99`c1#80FcrL3CQ*bQW4Z_ zVjpN(e)of?u8X}x$X&-;Gp3<7ZYrT~0n_&fl~~TA!eMlF)eUmI|7&ysabAlZl#{!6 z680|?Ok`ggt4$igSfWW%*0+E@Hci403wP&WmMS2py(Rq!5PL$c+Ekw*Agiw3k<6SHUEis!Q3myFX@QD07cVP>uv$z7ad zu;Ig>2SH~vPz0#FvV&Ia^{Ea`pwQVAKxxJ@k`$U4qpdNI_y zoayMe*tJg57*!d9D{Fgu=n!ySz;E_Eo}ceK-wt;b0hB2{TC6 zEYcqS2&hyQ`$leu8k`1lkh$Yj{?LCQRNx$jaX<6Rbd>wk#>iI0E7vZ` zdJTmXop8#_1h#sN4)yu#>L=`=2AD3x3L3TXG%NKxUS6c;D3C_ z5~(Vuf>(BNsBA9zj;G!ErRrs)PKwX^(AtxQ^t#N6&<&w~;`c)%uHW}4qmAAB9-Q(f zGZP>d^fLuSw`C^OYwyMIogmea-XE&%upSpD$}DIcXw9%o_%z=Oz7>R8$%5@*nZe}t z#Px3iG>iN(K`x!eK(i;)Bs|67yxAn8bbr%!7Ss{~H-ClC6x8Q^htR!h46>&9*s@gS zYq6zcopjwZXR<=8kr9tUvb#fxR;MUZ>;|@ynXiVt+DVe9fxck6_Gm6szZ3l{xQZfy zp`yx1b(V`G;>jr}m{7492wt5lzT87joF+&7l3owt%7BJ5n^Mj!G6(`i-*0g=Z4kE< zTYeXO1g;Vw%T5LMy2`v76Jw^J?`N08e7*zHJ^_S282ES*qjSnJiW%Amc4LW;Wp{zH zBvfo$@c8XT`ITK%(oE=0jSE@z5QgH8jEw_ga^_qIKc^uSOne#uS|DUa#MMA0N@OTN zi#Ye;Vqov-LV^14<@x#fKe39NN0Io=)Cfr@qe&`g^J9I%u5GERTlW5%vebT!+inOS z5^>8peu;N2!Zh1bpY#s1K!HN&h%7;Pkzsk1)o92ga_AWivL_8fw2fXer{RP`&M{~u zB&HBWI;okqB@2w*Rsq|Qir0dw!Mj9_vd`WeNYODU%XiS5Q=;%yfjiB}#ffWd@pMPP z+71zs3@oJqNuEI<6a#DRxbPb2(qU{|9l&b=rr|l|F1G!^cK33o&-w)O9%01(7R&OU zwV!)fXF@>j1;wXL>S&6}fjgZUTpugcP(XbA%BOwyOf6E$h%9ZXM@T}`>o@)KJpeNf zBAcJoi)?iD`WiZcj+ZYiMQ#cSvEU{Ryl~+$?kefrn04%0doMDz#flK{TL4Bke`qM# z()#9%2{*#Rr3=b=i`b>0V{9?TS)HMLR9^~X%eE#JZiG9{B1IEgmpiWvLQZlyNH@L} zLKSf`Ry#%#!MmOUE+#i6uJ zSX8lz!;Xwtw+{7>{vbh>3s|No@hA+GF^74V2t+C`gU-ZJ_*m2Gtm^bhkp8DRhfO{j z8M7=P7pi(g#--6f*VG4(xQiVyP&itsuyg4GT(@x2l1urv&@v7Ank`$nVvr}sOpUt& zzy7h=fW)$uHE6pjNFg>=MMkKA)d_Ff?#rMi*@DS`Kv8i!xJX+QpkCtD%PL*!$PopF z?YBV_Ik6B|9&F1{51&Ac{|W3<0)#IN1$<(3uF{j<^KH}u58;?FI|{e4+dApN&6!?2 z^Fye-3{};~#zvRGraN)_u^c!Y-;A8Ypgwv{O-{fWrwK(8o+)*SD+ook)YBS#iL?gz zzN;2xQDc1*1kCLg-k7hkTWE5A;Tfv2bs|43Et>CR`i>);kMr`uLvb!wA0%*Dm^vYv7 zbE%!RXHs3ZF@CLmcb3UA6%5jswacwb9|evWx%4345QHQY&aH|D;qBZ1_$^Q9F-KkC ziE4F$ueYCs$w-GV77Ptvk%_i&{|&WL0~O-{ehnwjw^_Mr^&d;okKxpF%GOQb!Af)vG_o~f0o zMs}Aq?XPj901&KaMqVj87^%_@3MexF^Y}gnz6DFcLGSwAih}1-3}t|&MTr{fXqgz@ z7D!B9-n#0vUW@b{5DEn)jdS%k*M192aNl#i0NS@r7pNQvbMRB7iZFb_$0^1Ng0BjW zvK_|R7Pr6*;FJZaBCx19q!s0cH^R8;nbe4;q{N6y9pH;(&SQ*TXk@6R)Vz&S!LL(&N1w=jc3^L|%M1uYA) zh;$)FX^SJW0KPnKk<~#*ypmZE>0(OYg2%fx3{zyyL`I~<)P8wuP)*Fv<*lQJ}VKLClc>o0$i3?sUr~b-GZqdmuoT zEPHSV$R_!;e0uIR#*LUAw?4gl$BS$J3X4(Mfk?uxvo!hfc6cYaxY=MRTffmt)%I0)YAUcG@-Eh;6n@_Iua$UDP^T3Y_%U>G;Es|1F zQZ7NJH(`TLO@XT0Y3&d=ao+Sn)tHG9^b*i`zAQo;+3Vb`2}~(Y)(kKP7}i9bj4>pQ zX%S2!C+0QBYO{^eF&0MQYH=$!!2YJ5&I>nS^^`Mwk-;!L*fBtG51vhg4ywFDYBRvR zce*2$uesvX7>xWQP|X=`IyKVLfQbNKa&5ef7DVlpn4gM06r(6?;Ucuv!GO>2%o;1- zwll{)!4{fd zoT}IS6-eD(=}e&*XQoa-7zz~0Kjjb@PL3K_yOO|!V05s9rF@T43FBab290sjtkvqd zj*FWQMQKB$BSC?A3)k$@8;p^*SQ*RqB+jxDM}#r3YHxjX73wWPMO*-m*#uo;jDts{ zg*D+@o;bDvGB}?c5Bp?=T9!(FSMvH6?g45-J1tlW_**$H`Pre0M{p{Dyyji3i4oKF z9_@bu)}g*A_NGPwB)9LY36%I`LYl2UXPMc`6<0hfeJFD4Ik{7)!yqT`cwmNJB`lgY zJ*dG6yzI|>3fWd!#$AZijmm~rI`{~`UjQ( zqZFn0cRUV{jRRs7^#hu}@l+g^2cJ9epTd}Dmc!Wq73kn) zMb(}*AFYd|{rAeLU2BM>Tek$&0+8NwE~EdZ_mc2XK<$9Kx)=y&FidRhqN1WIp&+Jm zBOMW?NG-JnbvA0Lu6#iOrVw0V04xW3-gLYlD+}OWZB<-ln-fuJdpp1;_dz?HD@0F9 zjQjHIpPbNbz$|_l;bw;b-e)LZ34M2cwQ@$l_X_{y1ZmPzh@J-;LxBLJOQL$8=c^Kz&r8{g>ugx;(-%U1;dI>u&&V z1KrY_|LGl5HME=T(ZQi8$5);UcGhVoS!=m1ECYpXgpsnz>l@xX!vhZ@1C*izRlC&Xca%JNvWLkc_!@kl9F?ZpjyOPlabW?y6#87db zp|je;_?;UkAmh1Yl=_HBRamg~R`bE~b>Z zduS5PX228Wm;$yaf{7KmC}~iwK7u2rg~9ND%sI`PcN0#X`uIG)yi-bzWSHA zRNN}yrJomJi6m1U3q*VF%&hWl$U;4>t6SC5f{0yg6qHNti$XlGl*$+p<+!mvHnR^p z98&2y4ET{8pPqD?cb^xE&IOK2;GUHp^oFCwIT91B!gbdeT}5us zd>B<0FFa#C0BO;b@Q@pZ24N$Dh+Cv#Czu@&>9Ba)Suc4ahsMW`_RWw^?bS?amWhHK zPoi_xOo_rI{kN2yK)pGkx{>|gm@Kg0;6fpKjgJx|+Qu1szt|3_FEOs=8lf}6>~X`3 z0s-DRYmh+|kGdPLY`qaCZk!D~C})CFZ{6zhl%Q!74(>P}Blds02ED`DEPQUTWwjc^ z8jKNUFQvv{5~X78=HK006O>v_({W;7+XD_u$XFZeqJETv$>&@Zi{C(OQ$Koy+J32t zeP3jDM=a|ZL4zG|^hqO@C+(ncn$Vr&^N^F{C^N;Ta3y! z*xDYc=f*NHD9O-EnRN*d5GxhNjdO)GSpARLBi)inx@odO!| z-wbRJlhzIhqluXrGqi86Xq0dEQ(AWUR3dC$J9>Ol6vWyGFPsP3C`9$P3>-^09Iho@ zQ!Gi2xxl?WkBX!kxLV+vx($aW;!%g5ku!*HIz>%_BQ7%1-P!Ivk-B(7TOaUo4FxtA zbx}hB0>BJR%oT<6Kr#V^X0PnCilNWV-JF)mU!WK)a)O#zSb&1sfA5fkLrPxG<~o;V zNc_oqo$E5GiR+4*m^tSa7sp#==9!mX{|2E8{o+!V40`-hW`{!Bpvx>84|X|J8d4S1_Oh4K)UpeaQ{20WBRVH@p#6q7nT3=j>aP6hX$BDcM0 zy+#H(7TVMmlsbLOP{4OG+~SE~Wv2;rdfiYw-;UD*+x@@uU1?eQ&t=OxB>rR(1V&okqVz>0#wBWn0R*4!no;R}_k zU|!p$g@`yE#!%fgLgno6xt@SwXRmnUw#723S^zibI{S`MuLS}OvRpppNTF1fE+}`1 zK8;HKg`5gZ2P3z(fWH0p#%ch(=L`>@lb4r#E8t{c?Ii*OY#iFTtMH}F0w?HMYjCx_QTi(yJIKtgdPmmoW``g66 z(Dp?HUk-x*SyNr<*|;df(??C!fYv;yxi9MPKDe$iDs7*@zyIj`x}~ z;rKg9)wF8hY$aV=K+uU_k>@a3`)5`LX{DvFxD*p(u(puUR3~_G0lfJOXdQZ#&`=W< zU$@8Z3p1&J#@9}>7Bb$?7mW!-wqi>X)5A~=Ev+3b7IVv*aZ)7{4nhQ(QDBT1^qi~x zkN1iParQ@Tw&UPjZ2{aW5= zf{1sMkPO4#9FTw{Gt{n?`-FsjOMIBvxuL=_d!6Ib)W-@Fmm$2PWx0U@?i``VTENTwu=>u0^I5fMkYSxY=#zi z5kJ}S@(znC{sJeB{EGXK=WlJ)x|eU0L(i#qduHalqwn*HijE{MW%Y>`@7Fv1ZmKEe z>z!RWckWuYPksh;Bj+Eye9~tgJ+^U>ws)M7nM_Ih`JAgzCz>5JzCvI^*Yp(&cpY$u%BIh7RJoCEc z^4S1l=Bi%ztQoZjy!vf2<1N`AuuvY3d$lk9uQ}|O`&1mP`jGi4TjpI2=@h_)#)a2- zDs3oAR%E(4g9qkXu2q^yk8YZJdb7JP0JPAuG%PEC+xCBWa>Tzb<$k$lmm&nwg|mk3 zo{l@Dpe&o_Ijb$-mq@@7$yK~Nh4F67wTj^9A(%fxz!c$J!fvcs zmR6JM02wpf>MSq*NFwWdvwP|mWGdbJy$<_bLO4qm&u0^%>CNj z*PN5~YcF#mLG@d=C`5c_P|`Kv&a$>u9S^3J7~+;bH8(vFT~b{YFaX0o@?Z4NuVfL0 z`QOw?qTNsoyF+!@$F28fm2&*x;C<09v9e&Mf}QwJA}SBQDo_;xHHhoyZx_db#CgZ& zR4I#sjvZT2XIka=VNJ=C5SBusq4-a=&B76EY)FqJJV5Vnd+HXsWo<4qZv%1S@cjK(e~0}&--{ONX@tj$fl#wGxM5T zoeQoJESh3MSK1AsG)SZ(jlwl?Vfszz*6>_sZh{fIp#;$8$=*ye)FW3EsmIq~q>_MR zI^>GBk-e9H0{<44{@dbNDn_yd>g?qp<_5rJ!-D`^UJu^oh)NF9y>_7g^)k%6{YOx} z9;pT%E-*GSS{|1MzVcYterObP&Q4-9s6iF`v$Af6y~fyYg*hnt_w zjNe{h)=W4Rn2l}@FzQb+S;4TkODkb?kuXaEQuJpX&(x@VZ;(PMlRh(fiG;~$qU77n z&EFDKx*{uTatVvPs3wTb2Q2>1qAy+BQ6Z*QNqkq4y&*fo%u^d06X zh6Xrb1v}BQpO32KznQusIeJJP?P}vXe@lGL1wqhk|0b_;{4>`&$|u(81VQ{Hw%0ic&pEIg!yE{srjfijSp1__kn)iHc? z=c@pQo0Vlbygqd0K_DLd$9F01gN4CH6Sw$eE3_+X|dlLcY(%%UdoE*_ssh)z75K@ zKyYN;>eg`xmAJOJ5@b4YGY-}|dI>S8+e$wNXc6|ha>zAxbK}hVXlViI`6R1X+uY%OZ99VONNnyxyTSNSZDVec4 zHwU^OaynRic|cy*+porduRPkpN}nEUC+GVO7luoAX!2`EL1^ z_1HM90#~aPyQLiP!{=`W{qLrg>jUY{;!>vbT22k`DomUvFW#q5d{v8i`~)kl+y~Wu z9OhB_hLE?BFe7;Ub}GTDYyxsB4=5ydX4S^?4*78OJM~85LySoA#9?8kKXs2`hQ3;k zI+fy2L&v|_FWNS8%NlaFuDcL97MTy<$Gt1bt;neGt?(99#5F-`X+Yf&FLBwlah73z zX?pKlEGQNOqfi=(r#Vk5F+T!PyCF^N#8-ue=djrUY=cfwS5(yPTGF4GI!=xf zXVAxY!y_9iWLt#m;#tSoOIe^iCUrv532d3nhN@z7LI6X_I{T-;E`DLmN&*d(<=0#* z_<2ay#p7$_K_celeM@%hS?E_sQlaPa5~EQC&4!L+%4^tpM{?QB5w5(@!JaN2@$k6Z#umJ zZm`%vGzeU%Q%DB*wOH`3@WCwy03&&NsnhuL1yU`k9XuTA_&I7{v`^4Zj@`nVyjs-y z)dJ?pY5w9_VgX{_#GT}!IJ1CS{e0hg6LMO~Urj}22Q@%70B@BUHhwk`!o%;s?Zc2T zK&%fk(X$@TMZG0LJ*3#cdh-%LwN?y?#FD`ju&l~^Osz_pW=wjfTxd^O$`Q4ng)b}- zlk~g^Y*GKODR6Mhe%%mlHLMx?n51_u}j+5*Lrb9=#a0H^|Yb_KHp@+BrZLCe`$bC0pEN*lXq zd!J8D6im2p<8;_+dLja90lz1Cj5c2K5o8{_NLNJFS1)HARb1vePe)R{*N{P!_6L94D}ehUu!A9PKB-s;-irJo`( zsIjQG{F_RglZ%>J_H>1KH(!z2DS~{2(F0)CF-Us+cR(*?p!M%vuSRhHDN12fkx&| z4c3qXdx;PIWuR>L# zKDQ4%Q9}?u0|s_XLc&O!Kr(8)7;)WA?A?HW4+V0;GuLkN6CYpo^%sD28TGOS-xY*! z);G+{AKSs{mie`1S07Kv888ZOk6C5-m!&k{vMNV%ZM^M#u1qXtfg33$@8u_K-{O1X z$Jm;mLp8n}9yvZ!Vtc@&gLgXU2QkRHfIPd{mZb88F}Jy0M3Y++Ci~XvU-USBJU4;b zNy(3?^>Y81ik_IrLM+V4i8rY7?%r7v5)2JFui)O>y9P>R0|YLXrFL+`YlvE!Zus>6 zjqF5fD;k}Hp&Xy=#uh-9#`?zRR63?WIZ*Ey#~D}Qv%+9$bMGN51^sZWDzqrs0H?F; zTj9qVIrXsw)uu0QeaTm=ClV8NwxdX98-RLS695~4W@1{b7A4qqlzNQ=%D)Mctb_Ll zM6nNsI{d^Qi?@{{@7t#}SeKV>$#!S;HDLnPlY7EP3RC=-4CQ64bqBVWp&9J0JUq97 zq&|3(9xPM9o{wQ48DrAd=>gD&7h>ta-S%#hH8J@t*LJ#BY^^l_H{~4apVImmfoK`1 zyKG0F=-KNxNJKMN)BJnRU^GvR2P{|1;T8kC$pX?Hd4emGr>sKGp z#E)?hYJoLvD5}1-(z2{=ah7z_5Iyj&qT7P7S$|KrTYL?eh1(C+6Mlvikd)balF8yQ zTb@b{z;|7z7`=GNo{n|2Tr&MakJMShu+7IZP}322#Dv1@l7lE_<)?KRZ_M@ClWcT4 z&Xa;+2{coR+|a6+2zM$YTQj?}ubSkM?mmuNYJE?v`W`$M86Y;m#?5Q1f28cYf4@cX zW=L6@P5~S+HE8sdb?Gn@Km`Y&vho!+)aaB3I&?CVe#kmtG_}1IEi;W{Q_CUL1y|Rt zl{KpOq|5Oq3;G0VbC7r{Pb3QPXR>Bd;3lffwbbvF%WcDkI^P#l-V6%L8Bo>T@JT1# zS#6m5-u_1<(ulVkul}5nRPOdmjJHPOz${&wPJa`{hga}dr~5+5-zOb-D$A+qw`y&sU+dS`($#0FiM ziee$WJcZ%P1AwIen+{0E?YO)6-$PN=LfB&8nN#>bBD z!r&+H5)RBP`?*ev$$~GZ_=HBbMYM2IFN{0Nopn>-xi+(c`;IK|vri*BOFo@3%$l z{p)kyCk$54d$=amv7ufQnE{Ls`tj^hTr;_knAM<@#-`@KNMYhG?Fqlfu$8M#`je2K zf4?&$Ofkch=LEa_Si0fKwe5s4>S8CG5yk4m%VqSfkz`I*n|}B*y8wZ@i#vs|Pm6@w z+Q>``bRoKy;|5!`7-d_ZaR6bPCUXs}rXynIiv-pz6vbZmbR`FqqS>3D~AF4~d zI{mrQ>k6v?0LDQ`!o*J-H&y~!x?}|{FW^^3nmW_z)#6yjs4Tr~l90HF1fbI2izXVEDoV)KmF>5<*Zxy zheMmvK9dx@I69|^L*Oq|G7-@_3jh4(YvhEi*9{@~I6E&9tdLc(D}cet?BQz!juo`j ztM~3Jg@w)FEBvdwV^3nWzc4Q4-;QxEzu3^_7W~d2Z!(qmJS|`vy(p18r^9CNVb8?d z&XL)b?5bY$(GOQKzAU=j_KGjZM8E9#@q3%!1+~Jr?)|h&>kq^HeTv!m;S*)O)T@i; zo62Z4ci%c&n*#bV?vj7jQT>Cle}wOP{loXWkMd~NU)}usaAS`riK#4 zr)%jOqh)u%{HcA<|HdHkh^a3YM7Qx{Ycr}W~x0KzAhy7ckJMqyV-L^HCmaD{Y5 z+SSCvmuG^{pO4+f&o3lcDHS}>iAa^q%Gohn%9&KKuDK)7?7Zw(#OjnB) zxuY3fYo0M@)!DNl=mxBMANl%TgJh7t^#l%`zFNx{yhoxtd3>!f5)Is65<3}$qw5K5 zGwJG(;;WdNeYmOMrq7je=I3n1 zTMZ2CRH@$lidR2AEN7h;6L4~wzz9Z}d^$yFuBrWy`)Bu#=IX@7!dz-};6Af+_Hu6J zwUS1e`AdJ?bInu=TFj?(mox*!HakID}ic4=pLqkz_w?#%~ zW`211dZ|5oayAMdp3euEI%D_{6o390k}E9~)d{t|gXZP13y~OQ-}=tZ&dh>>)}eZg z3!nN@j$UB@HD1?UkWmwT2CLT7(EQqw{R&`C!M)|IMY39UadA5v@7T0!QXcD)T~d{{ z&%6*?MG9-TwX|H6c{TN?Z|*!F+Sj*8kCCFjD4wY3`Rq)Db%dmhj6&q;GfVE-(b1dw z;>SEbqm|Rk&N+5zmCJot*TuJ}>)?>aSACatq}7+I@L(m@_`vaN41$N}ZSquP($El= zU6xU+)R06SpdII3x6V<%#e8^li%-cHe*S=ANl658YNyljsH9h2&X=GyyFPRHV%4%J{ooZ zt^eaIKdg0pejvzrsXU@`Xf1U1jUgT$u;<=*NvA;Ziaye@EUqDIp zq95@IAlHMD)uZ$2)#V89M@M?ulP0@uEd}ZsR~jleuqHLv%Gqz6_YJ-jOWMkBX7xup zNdmw2mFrvWNdaAqGqh5C@H9-~Nr$?^wiEFMRcsL#xBR%EGL+c`H}~ToPK;-{H|k38 zN3m5qn}x^PSaUJ2u;yoN7VwDsMs`>M(|U{1ZPk) zOO(-0{=7oRkIA80ZC9!5u!qT|0j)Zs|Cec{`yl zQGqcfp*H>F?*vRx40OPsE#K1cBTR1EXDH2e75uzSeQQBN8YG)L}XHEcKyD9#lH$B{NGk@8>Sz67Ri>mYZ-jw7kTNn zp3&OVGCnA~oEZ@TgI@dr2L<>lJ}WIz1Y`J=R=X!1R~L5zmmML>;<@;{!682Ase zEga9XA^j|X4apU~=&9GdmjMyndxo;118NJ#n?K`;f&qN{`i{GP#-~GW{MC2w3s0mgj9THz-^QGrXu+8&Z-r-nlYG=6eD00{tgA>3@w}y)lTr^9 zrPOe!+C&xqiu~^R@@c@F0Cop!38 z0`tY$d1OZi>BlcR}xZWor^+h;s#KY)O~d-qtmn3MBNF#VPt zY^yqvtg?0n-3LYJz-djEW_)->Xcl3YC;hpLoS;~j{2R&WutPNZclX;>x9$G$3-q@& zsc>vg9BVym8s$QL7O>Xw6)V8~cHb-iS=W_ds``tbf0gG7V-#tnYb}XCt6h1 z`OLJE$+6u}vQ3MxJfYVPQvHq{yM`CZpXgLOq)jaR!vqUQj5VK7%5ja7ABGvR*?4?U zgUH%TqrMi5VMwJ{qs&ha60jPAx5lbAM{fIG{0*zc!P&*-a%a91^T}p+;sRPRG(axy z^JMSqH-G&??lJss$9kzBul>fECSZ8IbA_~p`#R+63-V_z@_3+~U1NSq(N5Fhf$RKE zPNQO2<8!vnV*V{*r-<1iu{=?qU9yXo#{7-W9%}u)!CKblVcZ?%XdxENl+E~qLe~=C zy6JLnI*F#%`Dv=0RYpHWEgXNeBjTOy;rs7zI65TKyI9Ob`x5`d3mCe2envH5(p_gt zy|K*yS|lyqlCT*nS?bqBv`*b!vV0B8@*B-)0l*tLz1tc|PN6)i|O&EE4X4QrJjB+lf zSE?<#s9mn168Yv`^@1?oBI<5UrDhrIvWEHM_X&g3>hDqlHWpXDw6J&6jowzJ>=dqf z2Ax{e=auFyV`95a>i@Cz-tkoT@&9l;DN&(1#!0k{l#J}ml)YEhRkBBMvd^J7l8got zviH`pIVTiFIL2|zbCPVwu@C1s$NfHiuj}{yJ?{Jd{81`-IL>>#UeEP1A1(45_n7LI z!R+XQOAM;2VtEyI^G~ZeZ1vnu2$piinD$s;VSJ#KkE=mRww1O1c!( zpWol2hbAUSAuv!8Z-GzT+`xp{jy3=DjTOpxp}c(%)bt-Q_iSmr56!KuQz$T79d(d!VPTYurxQfvd)<6%VrjHaf(|X^yQ!ad0Aas>m=nBvJ8w=1UhKg>*Ht)3Whw( z2%$_kiA-%4ZY`EreqJ)d%GP;|NtdT=k;Z1HR3|8Z-sw|i3q2TLjw;!|yR2Jv{?_Aj zWja)`fqLw~z(ez@a`TH$xNQiB zGcqujin3eDdTSI@r+M0?OPBsnGw?r}y{QkXlIrY-3kTT#2CRSC=8oE{wJj;`3jAnY z(pXl5XgVO-(34g@`KleGJetW}AE7~DF1DomhAxGahq=uy$KSNxOUzz*mCDPbC#veT zedlKu$s=)K;$dHdaP+f0m!BhNDlK-&>(1s)#pNU#+(eASqoB2+s1PQJKH1l*3Q9#w z+a?i6B-=dKr%cTiS0UbI^TW9z9qzy_&OejUzOmpdu016buJC7iI^dy|)QX7(av8=p z4NpR6Uza>-n!PzQ^EgGTb!!j@Cf)hWQad6;L|i#}~p+m>>V18oG{MQS*b%lb8~^9PCUaxTxRr@867 z-E9F)x&WiwH#Piw%##B5$mz10>8Tx_Kl3=qFYv~ z-s(gzMs%FW2YEbA*7fx{vYa0oHIxr`?yHfGeztW)@~FGDq&xCL%NpL2w%ol!y&HUVF`Etq=-zfmGvX53 zTN6gK-;D0GW7)XJ;E@it+x+ha){*=VR0F!i8_~;R=|8SrMYS%3 z?S~w`A+?Xq_UkJm)b8Vmz-Z)b7WAM&x$(dBpZ6@R#TQm07{72c4 z1#3`%JRnu$F*YLrk+29ws@{IKJU-v%`u+MMzMRayc`lCa1Hkd8=_dOj0$BC2k|PxY z{`(##b)p`)?1xA=c5;$YB-V5B*a~?A!EG)Pv+b2cF>53EwJ+wYeU(l>o#2dFq#Ztd`2TgtjsxY(H^@%iOSQ zZn-EXmS|Vx$jP?*^XI~TVwZ?GnS{C?Le$??0AKS)-}~-QTzh*THq{$SHaDI`KYM1? zQTakim+cr%T@I4|Y2z(vLqRKBCNO&ncQ&YFddB7PP>6l`x4;HbTJmOyB4Z*Iy*c-C z+9D_CXMwe5|@N=ExhkS5y-T1^7VOy|T2^lChB!PRQM3_OecTEji z8os&rnwpvUw2GrFzeOVfW70ao1>l?3Y#3Tdb zF5$|B$*e8$l%`olf--=!$bxAd!0~;aga2L0CAgx-ELv6MQ^3r5s2@mOqoU0OSKT2g z`NrU$+71WIqKIb<`Mvp`H`~VzY;g_ahZjy zJDz}F5q4Je#a^K)pI7b>2wk$F&4o1WY-BH_&oXNfu=iBAw;jyU)-)@5jrtgap!GkC9_;S(G zyx-06xvTE?TR5l_CQ0Xz9M9izi`y=>fqIW`eLrNBKg~x@9}0r)T?1!R3 z&|EN&TIkD#BN3`mTWQXi+~Zw=psH&s570@ugEv%L(a76(g7lfA=zUbDxp2`HQ{|$6 z@8{4cCT0IW&zEq{Xm!ycyuC>O=-3Ux#ElAAPV+>=qTA8e(KXT?joe@hTa+{gfj+iX zb%1u#=XVG!G07=gthV5LR^US^uRZ1$?eC1+Q?xa)a+R%osnI1I{2-_L$NQAbJS2bX zjrSMWw4HN4Ozq9F;VYJ=w+<_-hbYN1Z(R$O%;|*0Z+O^0;pfA>qje;tws?<8m6+xu zzjJXlcRZLp?W$A05>`_)=y1jLb@36BYcS7{SPJQ!2oXy_J1tRl$GVP~S`bX*ocan?jH=ZwAIbpe9A^DpK=~ zh;(;XW`S2G-MpGIE!p<2#~Mjp9JD6n|`y!78e(jK!LOJ zWM}GX#fl(w=Ou+gCX!>`-3pk`OZX7sl`yId;O_;~s z5VF@O>PPRp% zf2A*$W0a;379J~OYq_OdjhPh{VvNiMnZMS|MEO6YW@h3Mlx z|A{Pg!R|euCjJIalh`ZzpO)IzXSzRwX22Ri$+|*v%3!4%>2=K)yO~nPf$>eL$rUH8 ze1u6}@F@lkEK}Lp{bX6+Ta`ZulQKvcaN~dce;NDJ&^0{9zkJk|L|m zHmTDXpRkqM3^rbmmCFQ8QDEA>cLMv8eU_V|JgiQjANHh;M0Pu6ROq3zSBd&JYCh1mrP9$)-%S?Es8yhf&yTsfC?=G0r^_$#S2 z);fdy_U&Xyu9KBG%+;MAk}5KM2Oy2j`!Nd__;{r5_g)?s8jQ)z3}$)6;=op?!1*at zufuebBPqJaD{WTTcpmN}E^~PBYKF?R%!F55Pf}-Rr$D4sI8l!D!gmbHDEr_%tFs;C zSV3HO?^JIHm}?Y%cxKh_Kikv)axkx7m5o|~8+U6@9>7-i`u)i5BzvSyKxY-xPM}LS({Rkka@E(F@Uif0Il_z)pK)pUfHQdp*D{C2=xaR z88%W~>*V{7e0LQMo@_k~9Rk4cGw0Vu(}gM+QJaQAp1q=*A;Zg)Sa`oQb4T+SyAeUC z%4mKX02Je1v>xjG?TDCYfITY&sD~vJmD2yDFhAvcLVc_(#Nfe$rL-YGz&I$09b$WM zU-+yrl+_4((=ge%AS}Epb1GvqRVgG8@o>3iNSAHiE?}Aqx~x;Td=sL&65W&HGLW}Z z2Z&7SZ9bob^ST4*!N5p^BP4u9dPlZ_SNOgtbv2itr9Rs07M1@*r2Bv~V*O#U1Qm#v zzAI-xOdk$?Fg?{sq?fTT2EXMnxMkw7CvJuM-lci_=-`U7jSctJpHr0`{OgmlAAgXuCj^VxKB#nZiJmq2+OwJZ5y~W}g_?iV?^CXM2F3{Xj z_K!|15B=$z_z;4_UMx3X*NXg&hwUeb`f>!TyQErgc+zHsSD_x>$%J3^uZuv`$Bf(%QLN*B@^$@n_xSoV?wgCXYuraXx-~9jm^`36EUA{H6Lq+M;k=6yo8f zoTuuK_^a-PhkhwW<8Wmqab{gT}IXkR-i)L?k_Q^8DL#8JT)9#qaX30BUM7zg3Ju z+@m1~!_xI(qzOMFDduH)V(vqcadx28qoi8FJ8Fx$$X41GX9?!1VwAjoN2CF9HyZACP+q;A*Rx*97IJogSb)dA+9{>bNTP1GA!bL~f zB&tJj#DE7Ai6qJq2j@SCd{uFrWL7aQZ^G}G8{TO3L$C8e8SHzL%KmoykGHv~^Dh*) zv`QEm&V2*XmoTW3?_7NP0PB9kkWA{IPeIG8E>WNp;XZ8XKGA@yg@MjRNf#C$P8KP+ zGkte+Ar(y&jF32S3A8(<&z%7$H#NoVfXmmp9%aDHL%3DtP`w({CSB~)R3n!C5O4`^ z`q(TM(61CFsgD({KbWoS@xo~c{80s!N2oPzn-EBp%8Dj` zsZJF--))4YFNPGz_K!cR3_o(bZ~1zc2>)>11*ZcF<4mc*ueB5tBUeYj;pJrZG;m+v zdZL&gx2fM-`Js@_;V1HXZ8^ONJ$5GD*o5*puY2Q(fTZ9aYn@&8k>nq2H(y}AO=E0u z->qWYapx3FS3+CQsmFZ64O`kcFY(QkqvfPt;}ixtHbBe_08u8nQs0!_L>D+zK?K-0 zyD4Ear6kkrCom8jLkH_J)TULg;HIm8c91i+EdIDu=t-IyW&Lqas!O&Cg+*iCIQwaY zNWrB4Ic13Nl6p*{RPf3#74|Cz-uH !cQ_M?K18la(+*6wh?&YVlu2i)UN!Xx{I_ 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 literal 0 HcmV?d00001 diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 071f80de02..7db72da276 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -672,6 +672,7 @@ const sidebars = { "mcp_control", "mcp_cost", "mcp_guardrail", + "mcp_zero_trust", "mcp_troubleshoot", ] }, diff --git a/litellm/__init__.py b/litellm/__init__.py index 51c6683861..7f72e0b0e8 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1465,9 +1465,15 @@ if TYPE_CHECKING: from .llms.petals.completion.transformation import PetalsConfig as PetalsConfig from .llms.ollama.chat.transformation import OllamaChatConfig as OllamaChatConfig from .llms.ollama.completion.transformation import OllamaConfig as OllamaConfig - from .llms.sagemaker.completion.transformation import SagemakerConfig as SagemakerConfig - from .llms.sagemaker.chat.transformation import SagemakerChatConfig as SagemakerChatConfig - from .llms.sagemaker.nova.transformation import SagemakerNovaConfig as SagemakerNovaConfig + from .llms.sagemaker.completion.transformation import ( + SagemakerConfig as SagemakerConfig, + ) + from .llms.sagemaker.chat.transformation import ( + SagemakerChatConfig as SagemakerChatConfig, + ) + from .llms.sagemaker.nova.transformation import ( + SagemakerNovaConfig as SagemakerNovaConfig, + ) from .llms.cohere.chat.transformation import CohereChatConfig as CohereChatConfig from .llms.anthropic.experimental_pass_through.messages.transformation import ( AnthropicMessagesConfig as AnthropicMessagesConfig, diff --git a/litellm/_logging.py b/litellm/_logging.py index 5de9fbb355..18c3bcb7e8 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -17,7 +17,9 @@ if set_verbose is True: "`litellm.set_verbose` is deprecated. Please set `os.environ['LITELLM_LOG'] = 'DEBUG'` for debug logs." ) -_ENABLE_SECRET_REDACTION = os.getenv("LITELLM_DISABLE_REDACT_SECRETS", "").lower() != "true" +_ENABLE_SECRET_REDACTION = ( + os.getenv("LITELLM_DISABLE_REDACT_SECRETS", "").lower() != "true" +) _REDACTED = "REDACTED" @@ -199,7 +201,9 @@ class JsonFormatter(Formatter): json_record[key] = value if record.exc_info: - json_record["stacktrace"] = record.exc_text or self.formatException(record.exc_info) + json_record["stacktrace"] = record.exc_text or self.formatException( + record.exc_info + ) return safe_dumps(json_record) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index e0e1e35b94..ee3c344169 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1189,7 +1189,9 @@ def completion_cost( # noqa: PLR0915 and _usage["prompt_tokens_details"] != {} and _usage["prompt_tokens_details"] ): - prompt_tokens_details = _usage.get("prompt_tokens_details") or {} + prompt_tokens_details = ( + _usage.get("prompt_tokens_details") or {} + ) cache_read_input_tokens = prompt_tokens_details.get( "cached_tokens", 0 ) @@ -1515,7 +1517,9 @@ def completion_cost( # noqa: PLR0915 if custom_llm_provider == "azure_ai": model_for_additional_costs = request_model_for_cost if completion_response is not None: - hidden_params = getattr(completion_response, "_hidden_params", None) or {} + hidden_params = ( + getattr(completion_response, "_hidden_params", None) or {} + ) hidden_model = hidden_params.get("model") or hidden_params.get( "litellm_model_name" ) diff --git a/litellm/integrations/focus/destinations/factory.py b/litellm/integrations/focus/destinations/factory.py index 01ea6ca9cb..706e10624c 100644 --- a/litellm/integrations/focus/destinations/factory.py +++ b/litellm/integrations/focus/destinations/factory.py @@ -59,17 +59,14 @@ class FocusDestinationFactory: return {k: v for k, v in resolved.items() if v is not None} if provider == "vantage": resolved = { - "api_key": overrides.get("api_key") - or os.getenv("VANTAGE_API_KEY"), + "api_key": overrides.get("api_key") or os.getenv("VANTAGE_API_KEY"), "integration_token": overrides.get("integration_token") or os.getenv("VANTAGE_INTEGRATION_TOKEN"), "base_url": overrides.get("base_url") or os.getenv("VANTAGE_BASE_URL", "https://api.vantage.sh"), } if not resolved.get("api_key"): - raise ValueError( - "VANTAGE_API_KEY must be provided for Vantage exports" - ) + raise ValueError("VANTAGE_API_KEY must be provided for Vantage exports") if not resolved.get("integration_token"): raise ValueError( "VANTAGE_INTEGRATION_TOKEN must be provided for Vantage exports" diff --git a/litellm/integrations/langfuse/langfuse_prompt_management.py b/litellm/integrations/langfuse/langfuse_prompt_management.py index 03a93cd988..bea027aa63 100644 --- a/litellm/integrations/langfuse/langfuse_prompt_management.py +++ b/litellm/integrations/langfuse/langfuse_prompt_management.py @@ -340,9 +340,9 @@ class LangfusePromptManagement(LangFuseLogger, PromptManagementBase, CustomLogge ) status_message = str(kwargs.get("exception", "Unknown error")) if standard_logging_object is not None: - status_message = standard_logging_object.get( - "error_str", None - ) or status_message + status_message = ( + standard_logging_object.get("error_str", None) or status_message + ) langfuse_logger_to_use.log_event_on_langfuse( start_time=start_time, end_time=end_time, diff --git a/litellm/integrations/vantage/vantage_logger.py b/litellm/integrations/vantage/vantage_logger.py index 6689d93274..e0942472be 100644 --- a/litellm/integrations/vantage/vantage_logger.py +++ b/litellm/integrations/vantage/vantage_logger.py @@ -83,7 +83,9 @@ class VantageLogger(FocusLogger): verbose_logger.debug( "VantageLogger initialized (integration_token=%s)", - resolved_token[:4] + "***" if resolved_token and len(resolved_token) > 4 else "***", + resolved_token[:4] + "***" + if resolved_token and len(resolved_token) > 4 + else "***", ) async def initialize_focus_export_job(self) -> None: @@ -128,9 +130,7 @@ class VantageLogger(FocusLogger): callback_type=VantageLogger ) if not vantage_loggers: - verbose_logger.debug( - "No Vantage logger registered; skipping scheduler" - ) + verbose_logger.debug("No Vantage logger registered; skipping scheduler") return vantage_logger = cast(VantageLogger, vantage_loggers[0]) diff --git a/litellm/litellm_core_utils/default_encoding.py b/litellm/litellm_core_utils/default_encoding.py index 24533feecc..f704ba568d 100644 --- a/litellm/litellm_core_utils/default_encoding.py +++ b/litellm/litellm_core_utils/default_encoding.py @@ -26,7 +26,9 @@ if custom_cache_dir: else: cache_dir = filename -os.environ["TIKTOKEN_CACHE_DIR"] = cache_dir # use local copy of tiktoken b/c of - https://github.com/BerriAI/litellm/issues/1071 +os.environ[ + "TIKTOKEN_CACHE_DIR" +] = cache_dir # use local copy of tiktoken b/c of - https://github.com/BerriAI/litellm/issues/1071 import tiktoken import time @@ -48,4 +50,3 @@ for attempt in range(_max_retries): # Exponential backoff with jitter to reduce collision probability delay = _retry_delay * (2**attempt) + random.uniform(0, 0.1) time.sleep(delay) - diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 01565b9947..826396a70d 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -352,9 +352,9 @@ class Logging(LiteLLMLoggingBaseClass): ) self.function_id = function_id self.streaming_chunks: List[Any] = [] # for generating complete stream response - self.sync_streaming_chunks: List[Any] = ( - [] - ) # for generating complete stream response + self.sync_streaming_chunks: List[ + Any + ] = [] # for generating complete stream response self.log_raw_request_response = log_raw_request_response # Initialize dynamic callbacks @@ -782,9 +782,9 @@ class Logging(LiteLLMLoggingBaseClass): prompt_spec=prompt_spec, dynamic_callback_params=dynamic_callback_params, ): - self.model_call_details["prompt_integration"] = ( - logger.__class__.__name__ - ) + self.model_call_details[ + "prompt_integration" + ] = logger.__class__.__name__ return logger except Exception: # If check fails, continue to next logger @@ -852,9 +852,9 @@ class Logging(LiteLLMLoggingBaseClass): if anthropic_cache_control_logger := AnthropicCacheControlHook.get_custom_logger_for_anthropic_cache_control_hook( non_default_params ): - self.model_call_details["prompt_integration"] = ( - anthropic_cache_control_logger.__class__.__name__ - ) + self.model_call_details[ + "prompt_integration" + ] = anthropic_cache_control_logger.__class__.__name__ return anthropic_cache_control_logger ######################################################### @@ -866,9 +866,9 @@ class Logging(LiteLLMLoggingBaseClass): internal_usage_cache=None, llm_router=None, ) - self.model_call_details["prompt_integration"] = ( - vector_store_custom_logger.__class__.__name__ - ) + self.model_call_details[ + "prompt_integration" + ] = vector_store_custom_logger.__class__.__name__ # Add to global callbacks so post-call hooks are invoked if ( vector_store_custom_logger @@ -928,9 +928,9 @@ class Logging(LiteLLMLoggingBaseClass): model ): # if model name was changes pre-call, overwrite the initial model call name with the new one self.model_call_details["model"] = model - self.model_call_details["litellm_params"]["api_base"] = ( - self._get_masked_api_base(additional_args.get("api_base", "")) - ) + self.model_call_details["litellm_params"][ + "api_base" + ] = self._get_masked_api_base(additional_args.get("api_base", "")) def pre_call(self, input, api_key, model=None, additional_args={}): # noqa: PLR0915 # Log the exact input to the LLM API @@ -959,10 +959,10 @@ class Logging(LiteLLMLoggingBaseClass): try: # [Non-blocking Extra Debug Information in metadata] if turn_off_message_logging is True: - _metadata["raw_request"] = ( - "redacted by litellm. \ + _metadata[ + "raw_request" + ] = "redacted by litellm. \ 'litellm.turn_off_message_logging=True'" - ) else: curl_command = self._get_request_curl_command( api_base=additional_args.get("api_base", ""), @@ -973,34 +973,34 @@ class Logging(LiteLLMLoggingBaseClass): _metadata["raw_request"] = str(curl_command) # split up, so it's easier to parse in the UI - self.model_call_details["raw_request_typed_dict"] = ( - RawRequestTypedDict( - raw_request_api_base=str( - additional_args.get("api_base") or "" - ), - raw_request_body=self._get_raw_request_body( - additional_args.get("complete_input_dict", {}) - ), - # NOTE: setting ignore_sensitive_headers to True will cause - # the Authorization header to be leaked when calls to the health - # endpoint are made and fail. - raw_request_headers=self._get_masked_headers( - additional_args.get("headers", {}) or {}, - ), - error=None, - ) + self.model_call_details[ + "raw_request_typed_dict" + ] = RawRequestTypedDict( + raw_request_api_base=str( + additional_args.get("api_base") or "" + ), + raw_request_body=self._get_raw_request_body( + additional_args.get("complete_input_dict", {}) + ), + # NOTE: setting ignore_sensitive_headers to True will cause + # the Authorization header to be leaked when calls to the health + # endpoint are made and fail. + raw_request_headers=self._get_masked_headers( + additional_args.get("headers", {}) or {}, + ), + error=None, ) except Exception as e: - self.model_call_details["raw_request_typed_dict"] = ( - RawRequestTypedDict( - error=str(e), - ) + self.model_call_details[ + "raw_request_typed_dict" + ] = RawRequestTypedDict( + error=str(e), ) - _metadata["raw_request"] = ( - "Unable to Log \ + _metadata[ + "raw_request" + ] = "Unable to Log \ raw request: {}".format( - str(e) - ) + str(e) ) if getattr(self, "logger_fn", None) and callable(self.logger_fn): try: @@ -1301,13 +1301,13 @@ class Logging(LiteLLMLoggingBaseClass): for callback in callbacks: try: if isinstance(callback, CustomLogger): - response: Optional[MCPPostCallResponseObject] = ( - await callback.async_post_mcp_tool_call_hook( - kwargs=kwargs, - response_obj=post_mcp_tool_call_response_obj, - start_time=start_time, - end_time=end_time, - ) + response: Optional[ + MCPPostCallResponseObject + ] = await callback.async_post_mcp_tool_call_hook( + kwargs=kwargs, + response_obj=post_mcp_tool_call_response_obj, + start_time=start_time, + end_time=end_time, ) ###################################################################### # if any of the callbacks modify the response, use the modified response @@ -1502,9 +1502,9 @@ class Logging(LiteLLMLoggingBaseClass): verbose_logger.debug( f"response_cost_failure_debug_information: {debug_info}" ) - self.model_call_details["response_cost_failure_debug_information"] = ( - debug_info - ) + self.model_call_details[ + "response_cost_failure_debug_information" + ] = debug_info return None try: @@ -1530,9 +1530,9 @@ class Logging(LiteLLMLoggingBaseClass): verbose_logger.debug( f"response_cost_failure_debug_information: {debug_info}" ) - self.model_call_details["response_cost_failure_debug_information"] = ( - debug_info - ) + self.model_call_details[ + "response_cost_failure_debug_information" + ] = debug_info return None @@ -1688,9 +1688,9 @@ class Logging(LiteLLMLoggingBaseClass): result=logging_result ) - self.model_call_details["standard_logging_object"] = ( - self._build_standard_logging_payload(logging_result, start_time, end_time) - ) + self.model_call_details[ + "standard_logging_object" + ] = self._build_standard_logging_payload(logging_result, start_time, end_time) if ( standard_logging_payload := self.model_call_details.get( @@ -1768,9 +1768,9 @@ class Logging(LiteLLMLoggingBaseClass): end_time = datetime.datetime.now() if self.completion_start_time is None: self.completion_start_time = end_time - self.model_call_details["completion_start_time"] = ( - self.completion_start_time - ) + self.model_call_details[ + "completion_start_time" + ] = self.completion_start_time self.model_call_details["log_event_type"] = "successful_api_call" self.model_call_details["end_time"] = end_time @@ -1807,10 +1807,10 @@ class Logging(LiteLLMLoggingBaseClass): end_time=end_time, ) elif isinstance(result, dict) or isinstance(result, list): - self.model_call_details["standard_logging_object"] = ( - self._build_standard_logging_payload( - result, start_time, end_time - ) + self.model_call_details[ + "standard_logging_object" + ] = self._build_standard_logging_payload( + result, start_time, end_time ) if ( standard_logging_payload := self.model_call_details.get( @@ -1819,9 +1819,9 @@ class Logging(LiteLLMLoggingBaseClass): ) is not None: emit_standard_logging_payload(standard_logging_payload) elif standard_logging_object is not None: - self.model_call_details["standard_logging_object"] = ( - standard_logging_object - ) + self.model_call_details[ + "standard_logging_object" + ] = standard_logging_object else: self.model_call_details["response_cost"] = None @@ -1979,17 +1979,17 @@ class Logging(LiteLLMLoggingBaseClass): verbose_logger.debug( "Logging Details LiteLLM-Success Call streaming complete" ) - self.model_call_details["complete_streaming_response"] = ( - complete_streaming_response - ) - self.model_call_details["response_cost"] = ( - self._response_cost_calculator(result=complete_streaming_response) - ) + self.model_call_details[ + "complete_streaming_response" + ] = complete_streaming_response + self.model_call_details[ + "response_cost" + ] = self._response_cost_calculator(result=complete_streaming_response) ## STANDARDIZED LOGGING PAYLOAD - self.model_call_details["standard_logging_object"] = ( - self._build_standard_logging_payload( - complete_streaming_response, start_time, end_time - ) + self.model_call_details[ + "standard_logging_object" + ] = self._build_standard_logging_payload( + complete_streaming_response, start_time, end_time ) if ( standard_logging_payload := self.model_call_details.get( @@ -2323,10 +2323,10 @@ class Logging(LiteLLMLoggingBaseClass): ) else: if self.stream and complete_streaming_response: - self.model_call_details["complete_response"] = ( - self.model_call_details.get( - "complete_streaming_response", {} - ) + self.model_call_details[ + "complete_response" + ] = self.model_call_details.get( + "complete_streaming_response", {} ) result = self.model_call_details["complete_response"] openMeterLogger.log_success_event( @@ -2350,10 +2350,10 @@ class Logging(LiteLLMLoggingBaseClass): ) else: if self.stream and complete_streaming_response: - self.model_call_details["complete_response"] = ( - self.model_call_details.get( - "complete_streaming_response", {} - ) + self.model_call_details[ + "complete_response" + ] = self.model_call_details.get( + "complete_streaming_response", {} ) result = self.model_call_details["complete_response"] @@ -2492,9 +2492,9 @@ class Logging(LiteLLMLoggingBaseClass): if complete_streaming_response is not None: print_verbose("Async success callbacks: Got a complete streaming response") - self.model_call_details["async_complete_streaming_response"] = ( - complete_streaming_response - ) + self.model_call_details[ + "async_complete_streaming_response" + ] = complete_streaming_response try: if self.model_call_details.get("cache_hit", False) is True: @@ -2505,10 +2505,10 @@ class Logging(LiteLLMLoggingBaseClass): model_call_details=self.model_call_details ) # base_model defaults to None if not set on model_info - self.model_call_details["response_cost"] = ( - self._response_cost_calculator( - result=complete_streaming_response - ) + self.model_call_details[ + "response_cost" + ] = self._response_cost_calculator( + result=complete_streaming_response ) verbose_logger.debug( @@ -2521,10 +2521,10 @@ class Logging(LiteLLMLoggingBaseClass): self.model_call_details["response_cost"] = None ## STANDARDIZED LOGGING PAYLOAD - self.model_call_details["standard_logging_object"] = ( - self._build_standard_logging_payload( - complete_streaming_response, start_time, end_time - ) + self.model_call_details[ + "standard_logging_object" + ] = self._build_standard_logging_payload( + complete_streaming_response, start_time, end_time ) # print standard logging payload @@ -2551,9 +2551,9 @@ class Logging(LiteLLMLoggingBaseClass): # _success_handler_helper_fn if self.model_call_details.get("standard_logging_object") is None: ## STANDARDIZED LOGGING PAYLOAD - self.model_call_details["standard_logging_object"] = ( - self._build_standard_logging_payload(result, start_time, end_time) - ) + self.model_call_details[ + "standard_logging_object" + ] = self._build_standard_logging_payload(result, start_time, end_time) # print standard logging payload if ( @@ -2796,18 +2796,18 @@ class Logging(LiteLLMLoggingBaseClass): ## STANDARDIZED LOGGING PAYLOAD - self.model_call_details["standard_logging_object"] = ( - get_standard_logging_object_payload( - kwargs=self.model_call_details, - init_response_obj={}, - start_time=start_time, - end_time=end_time, - logging_obj=self, - status="failure", - error_str=str(exception), - original_exception=exception, - standard_built_in_tools_params=self.standard_built_in_tools_params, - ) + self.model_call_details[ + "standard_logging_object" + ] = get_standard_logging_object_payload( + kwargs=self.model_call_details, + init_response_obj={}, + start_time=start_time, + end_time=end_time, + logging_obj=self, + status="failure", + error_str=str(exception), + original_exception=exception, + standard_built_in_tools_params=self.standard_built_in_tools_params, ) return start_time, end_time @@ -3774,9 +3774,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915 service_name=arize_config.project_name, ) - os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = ( - f"space_id={arize_config.space_key or arize_config.space_id},api_key={arize_config.api_key}" - ) + os.environ[ + "OTEL_EXPORTER_OTLP_TRACES_HEADERS" + ] = f"space_id={arize_config.space_key or arize_config.space_id},api_key={arize_config.api_key}" for callback in _in_memory_loggers: if ( isinstance(callback, ArizeLogger) @@ -3802,13 +3802,13 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915 existing_attrs = os.environ.get("OTEL_RESOURCE_ATTRIBUTES", "") # Add openinference.project.name attribute if existing_attrs: - os.environ["OTEL_RESOURCE_ATTRIBUTES"] = ( - f"{existing_attrs},openinference.project.name={arize_phoenix_config.project_name}" - ) + os.environ[ + "OTEL_RESOURCE_ATTRIBUTES" + ] = f"{existing_attrs},openinference.project.name={arize_phoenix_config.project_name}" else: - os.environ["OTEL_RESOURCE_ATTRIBUTES"] = ( - f"openinference.project.name={arize_phoenix_config.project_name}" - ) + os.environ[ + "OTEL_RESOURCE_ATTRIBUTES" + ] = f"openinference.project.name={arize_phoenix_config.project_name}" # Set Phoenix project name from environment variable phoenix_project_name = os.environ.get("PHOENIX_PROJECT_NAME", None) @@ -3816,19 +3816,19 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915 existing_attrs = os.environ.get("OTEL_RESOURCE_ATTRIBUTES", "") # Add openinference.project.name attribute if existing_attrs: - os.environ["OTEL_RESOURCE_ATTRIBUTES"] = ( - f"{existing_attrs},openinference.project.name={phoenix_project_name}" - ) + os.environ[ + "OTEL_RESOURCE_ATTRIBUTES" + ] = f"{existing_attrs},openinference.project.name={phoenix_project_name}" else: - os.environ["OTEL_RESOURCE_ATTRIBUTES"] = ( - f"openinference.project.name={phoenix_project_name}" - ) + os.environ[ + "OTEL_RESOURCE_ATTRIBUTES" + ] = f"openinference.project.name={phoenix_project_name}" # auth can be disabled on local deployments of arize phoenix if arize_phoenix_config.otlp_auth_headers is not None: - os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = ( - arize_phoenix_config.otlp_auth_headers - ) + os.environ[ + "OTEL_EXPORTER_OTLP_TRACES_HEADERS" + ] = arize_phoenix_config.otlp_auth_headers for callback in _in_memory_loggers: if ( @@ -3907,7 +3907,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915 from litellm.integrations.focus.focus_logger import FocusLogger for callback in _in_memory_loggers: - if type(callback) is FocusLogger: # exact match; exclude subclasses like VantageLogger + if ( + type(callback) is FocusLogger + ): # exact match; exclude subclasses like VantageLogger return callback # type: ignore focus_logger = FocusLogger() _in_memory_loggers.append(focus_logger) @@ -4013,9 +4015,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915 exporter="otlp_http", endpoint="https://langtrace.ai/api/trace", ) - os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = ( - f"api_key={os.getenv('LANGTRACE_API_KEY')}" - ) + os.environ[ + "OTEL_EXPORTER_OTLP_TRACES_HEADERS" + ] = f"api_key={os.getenv('LANGTRACE_API_KEY')}" for callback in _in_memory_loggers: if ( isinstance(callback, OpenTelemetry) @@ -4289,7 +4291,9 @@ def get_custom_logger_compatible_class( # noqa: PLR0915 from litellm.integrations.focus.focus_logger import FocusLogger for callback in _in_memory_loggers: - if type(callback) is FocusLogger: # exact match; exclude subclasses like VantageLogger + if ( + type(callback) is FocusLogger + ): # exact match; exclude subclasses like VantageLogger return callback elif logging_integration == "vantage": from litellm.integrations.vantage.vantage_logger import VantageLogger @@ -4937,10 +4941,10 @@ class StandardLoggingPayloadSetup: for key in StandardLoggingHiddenParams.__annotations__.keys(): if key in hidden_params: if key == "additional_headers": - clean_hidden_params["additional_headers"] = ( - StandardLoggingPayloadSetup.get_additional_headers( - hidden_params[key] - ) + clean_hidden_params[ + "additional_headers" + ] = StandardLoggingPayloadSetup.get_additional_headers( + hidden_params[key] ) else: clean_hidden_params[key] = hidden_params[key] # type: ignore @@ -5579,9 +5583,9 @@ def scrub_sensitive_keys_in_metadata(litellm_params: Optional[dict]): ): for k, v in metadata["user_api_key_metadata"].items(): if k == "logging": # prevent logging user logging keys - cleaned_user_api_key_metadata[k] = ( - "scrubbed_by_litellm_for_sensitive_keys" - ) + cleaned_user_api_key_metadata[ + k + ] = "scrubbed_by_litellm_for_sensitive_keys" else: cleaned_user_api_key_metadata[k] = v diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 2b838ad1f8..f600461671 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -2442,7 +2442,9 @@ def anthropic_messages_pt( # noqa: PLR0915 _document_content_element = cast( AnthropicMessagesDocumentParam, add_cache_control_to_content( - anthropic_content_element=cast(AnthropicMessagesDocumentParam, m), + anthropic_content_element=cast( + AnthropicMessagesDocumentParam, m + ), original_content_element=dict(m), ), ) @@ -2454,10 +2456,18 @@ def anthropic_messages_pt( # noqa: PLR0915 ) ) _file_content_element = add_cache_control_to_content( - anthropic_content_element=cast(AnthropicMessagesDocumentParam, _file_content_element), + anthropic_content_element=cast( + AnthropicMessagesDocumentParam, + _file_content_element, + ), original_content_element=dict(m), ) - user_content.append(cast(AnthropicMessagesDocumentParam,_file_content_element)) + user_content.append( + cast( + AnthropicMessagesDocumentParam, + _file_content_element, + ) + ) elif isinstance(user_message_types_block["content"], str): _anthropic_content_text_element: AnthropicMessagesTextParam = { "type": "text", diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index c1a6bd6750..3fda05172b 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -780,7 +780,7 @@ class LiteLLMAnthropicMessagesAdapter: # Keep Anthropic-native tools in their original format new_tools.append(tool) # type: ignore[arg-type] continue - + original_name = tool["name"] truncated_name = truncate_tool_name(original_name) diff --git a/litellm/llms/base_llm/videos/transformation.py b/litellm/llms/base_llm/videos/transformation.py index a2892e2060..87289ad6a0 100644 --- a/litellm/llms/base_llm/videos/transformation.py +++ b/litellm/llms/base_llm/videos/transformation.py @@ -336,9 +336,7 @@ class BaseVideoConfig(ABC): Returns: Tuple[str, Dict]: (url, data) for the POST request """ - raise NotImplementedError( - "video edit is not supported for this provider" - ) + raise NotImplementedError("video edit is not supported for this provider") def transform_video_edit_response( self, @@ -346,9 +344,7 @@ class BaseVideoConfig(ABC): logging_obj: LiteLLMLoggingObj, custom_llm_provider: Optional[str] = None, ) -> VideoObject: - raise NotImplementedError( - "video edit is not supported for this provider" - ) + raise NotImplementedError("video edit is not supported for this provider") def transform_video_extension_request( self, @@ -366,9 +362,7 @@ class BaseVideoConfig(ABC): Returns: Tuple[str, Dict]: (url, data) for the POST request """ - raise NotImplementedError( - "video extension is not supported for this provider" - ) + raise NotImplementedError("video extension is not supported for this provider") def transform_video_extension_response( self, @@ -376,9 +370,7 @@ class BaseVideoConfig(ABC): logging_obj: LiteLLMLoggingObj, custom_llm_provider: Optional[str] = None, ) -> VideoObject: - raise NotImplementedError( - "video extension is not supported for this provider" - ) + raise NotImplementedError("video extension is not supported for this provider") def get_error_class( self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 204fa4d0cc..4c9abaad90 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -6162,7 +6162,10 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url, files_list = video_provider_config.transform_video_create_character_request( + ( + url, + files_list, + ) = video_provider_config.transform_video_create_character_request( name=name, video=video, api_base=api_base, @@ -6230,7 +6233,10 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - url, files_list = video_provider_config.transform_video_create_character_request( + ( + url, + files_list, + ) = video_provider_config.transform_video_create_character_request( name=name, video=video, api_base=api_base, @@ -6324,11 +6330,7 @@ class BaseLLMHTTPHandler: ) try: - response = sync_httpx_client.get( - url=url, - headers=headers, - params=params - ) + response = sync_httpx_client.get(url=url, headers=headers, params=params) response.raise_for_status() return video_provider_config.transform_video_get_character_response( raw_response=response, @@ -6386,9 +6388,7 @@ class BaseLLMHTTPHandler: try: response = await async_httpx_client.get( - url=url, - headers=headers, - params=params + url=url, headers=headers, params=params ) response.raise_for_status() return video_provider_config.transform_video_get_character_response( diff --git a/litellm/llms/gemini/videos/transformation.py b/litellm/llms/gemini/videos/transformation.py index 0798472310..122cc95483 100644 --- a/litellm/llms/gemini/videos/transformation.py +++ b/litellm/llms/gemini/videos/transformation.py @@ -525,28 +525,47 @@ class GeminiVideoConfig(BaseVideoConfig): """Video delete is not supported.""" raise NotImplementedError("Video delete is not supported by Google Veo.") - def transform_video_create_character_request(self, name, video, api_base, litellm_params, headers): + def transform_video_create_character_request( + self, name, video, api_base, litellm_params, headers + ): raise NotImplementedError("video create character is not supported for Gemini") def transform_video_create_character_response(self, raw_response, logging_obj): raise NotImplementedError("video create character is not supported for Gemini") - def transform_video_get_character_request(self, character_id, api_base, litellm_params, headers): + def transform_video_get_character_request( + self, character_id, api_base, litellm_params, headers + ): raise NotImplementedError("video get character is not supported for Gemini") def transform_video_get_character_response(self, raw_response, logging_obj): raise NotImplementedError("video get character is not supported for Gemini") - def transform_video_edit_request(self, prompt, video_id, api_base, litellm_params, headers, extra_body=None): + def transform_video_edit_request( + self, prompt, video_id, api_base, litellm_params, headers, extra_body=None + ): raise NotImplementedError("video edit is not supported for Gemini") - def transform_video_edit_response(self, raw_response, logging_obj, custom_llm_provider=None): + def transform_video_edit_response( + self, raw_response, logging_obj, custom_llm_provider=None + ): raise NotImplementedError("video edit is not supported for Gemini") - def transform_video_extension_request(self, prompt, video_id, seconds, api_base, litellm_params, headers, extra_body=None): + def transform_video_extension_request( + self, + prompt, + video_id, + seconds, + api_base, + litellm_params, + headers, + extra_body=None, + ): raise NotImplementedError("video extension is not supported for Gemini") - def transform_video_extension_response(self, raw_response, logging_obj, custom_llm_provider=None): + def transform_video_extension_response( + self, raw_response, logging_obj, custom_llm_provider=None + ): raise NotImplementedError("video extension is not supported for Gemini") def get_error_class( diff --git a/litellm/llms/moonshot/chat/transformation.py b/litellm/llms/moonshot/chat/transformation.py index 40096be05c..24f852c28b 100644 --- a/litellm/llms/moonshot/chat/transformation.py +++ b/litellm/llms/moonshot/chat/transformation.py @@ -19,7 +19,8 @@ class MoonshotChatConfig(OpenAIGPTConfig): @overload def _transform_messages( self, messages: List[AllMessageValues], model: str, is_async: Literal[True] - ) -> Coroutine[Any, Any, List[AllMessageValues]]: ... + ) -> Coroutine[Any, Any, List[AllMessageValues]]: + ... @overload def _transform_messages( @@ -27,7 +28,8 @@ class MoonshotChatConfig(OpenAIGPTConfig): messages: List[AllMessageValues], model: str, is_async: Literal[False] = False, - ) -> List[AllMessageValues]: ... + ) -> List[AllMessageValues]: + ... def _transform_messages( self, messages: List[AllMessageValues], model: str, is_async: bool = False @@ -53,9 +55,13 @@ class MoonshotChatConfig(OpenAIGPTConfig): messages = handle_messages_with_content_list_to_str_conversion(messages) if is_async: - return super()._transform_messages(messages=messages, model=model, is_async=True) + return super()._transform_messages( + messages=messages, model=model, is_async=True + ) else: - return super()._transform_messages(messages=messages, model=model, is_async=False) + return super()._transform_messages( + messages=messages, model=model, is_async=False + ) def _get_openai_compatible_provider_info( self, api_base: Optional[str], api_key: Optional[str] @@ -141,7 +147,9 @@ class MoonshotChatConfig(OpenAIGPTConfig): optional_params["temperature"] = 0.3 return optional_params - def fill_reasoning_content(self, messages: List[AllMessageValues]) -> List[AllMessageValues]: + def fill_reasoning_content( + self, messages: List[AllMessageValues] + ) -> List[AllMessageValues]: """ Moonshot reasoning models require `reasoning_content` on every assistant message that contains tool_calls (multi-turn tool-calling flows). diff --git a/litellm/llms/runwayml/videos/transformation.py b/litellm/llms/runwayml/videos/transformation.py index 2c29c2e21e..8377dea952 100644 --- a/litellm/llms/runwayml/videos/transformation.py +++ b/litellm/llms/runwayml/videos/transformation.py @@ -592,28 +592,51 @@ class RunwayMLVideoConfig(BaseVideoConfig): return video_obj - def transform_video_create_character_request(self, name, video, api_base, litellm_params, headers): - raise NotImplementedError("video create character is not supported for RunwayML") + def transform_video_create_character_request( + self, name, video, api_base, litellm_params, headers + ): + raise NotImplementedError( + "video create character is not supported for RunwayML" + ) def transform_video_create_character_response(self, raw_response, logging_obj): - raise NotImplementedError("video create character is not supported for RunwayML") + raise NotImplementedError( + "video create character is not supported for RunwayML" + ) - def transform_video_get_character_request(self, character_id, api_base, litellm_params, headers): + def transform_video_get_character_request( + self, character_id, api_base, litellm_params, headers + ): raise NotImplementedError("video get character is not supported for RunwayML") def transform_video_get_character_response(self, raw_response, logging_obj): raise NotImplementedError("video get character is not supported for RunwayML") - def transform_video_edit_request(self, prompt, video_id, api_base, litellm_params, headers, extra_body=None): + def transform_video_edit_request( + self, prompt, video_id, api_base, litellm_params, headers, extra_body=None + ): raise NotImplementedError("video edit is not supported for RunwayML") - def transform_video_edit_response(self, raw_response, logging_obj, custom_llm_provider=None): + def transform_video_edit_response( + self, raw_response, logging_obj, custom_llm_provider=None + ): raise NotImplementedError("video edit is not supported for RunwayML") - def transform_video_extension_request(self, prompt, video_id, seconds, api_base, litellm_params, headers, extra_body=None): + def transform_video_extension_request( + self, + prompt, + video_id, + seconds, + api_base, + litellm_params, + headers, + extra_body=None, + ): raise NotImplementedError("video extension is not supported for RunwayML") - def transform_video_extension_response(self, raw_response, logging_obj, custom_llm_provider=None): + def transform_video_extension_response( + self, raw_response, logging_obj, custom_llm_provider=None + ): raise NotImplementedError("video extension is not supported for RunwayML") def get_error_class( diff --git a/litellm/llms/sagemaker/chat/transformation.py b/litellm/llms/sagemaker/chat/transformation.py index 60e85c9f93..3e42c1e8c1 100644 --- a/litellm/llms/sagemaker/chat/transformation.py +++ b/litellm/llms/sagemaker/chat/transformation.py @@ -184,9 +184,7 @@ class SagemakerChatConfig(OpenAIGPTConfig, BaseAWSLLM): llm_provider = LlmProviders(custom_llm_provider) except ValueError: llm_provider = LlmProviders.SAGEMAKER_CHAT - client = get_async_httpx_client( - llm_provider=llm_provider, params={} - ) + client = get_async_httpx_client(llm_provider=llm_provider, params={}) try: response = await client.post( diff --git a/litellm/llms/vertex_ai/batches/transformation.py b/litellm/llms/vertex_ai/batches/transformation.py index 86bdc2c7b5..c114465490 100644 --- a/litellm/llms/vertex_ai/batches/transformation.py +++ b/litellm/llms/vertex_ai/batches/transformation.py @@ -142,8 +142,8 @@ class VertexAIBatchTransformation: Gets the output file id from the Vertex AI Batch response """ - output_file_id: str = ( - response.get("outputInfo", OutputInfo()).get("gcsOutputDirectory", "") + output_file_id: str = response.get("outputInfo", OutputInfo()).get( + "gcsOutputDirectory", "" ) if output_file_id: output_file_id = output_file_id.rstrip("/") + "/predictions.jsonl" diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index 07b3d6faf7..1c24d657c1 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -624,28 +624,51 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase): """Video delete is not supported.""" raise NotImplementedError("Video delete is not supported by Vertex AI Veo.") - def transform_video_create_character_request(self, name, video, api_base, litellm_params, headers): - raise NotImplementedError("video create character is not supported for Vertex AI") + def transform_video_create_character_request( + self, name, video, api_base, litellm_params, headers + ): + raise NotImplementedError( + "video create character is not supported for Vertex AI" + ) def transform_video_create_character_response(self, raw_response, logging_obj): - raise NotImplementedError("video create character is not supported for Vertex AI") + raise NotImplementedError( + "video create character is not supported for Vertex AI" + ) - def transform_video_get_character_request(self, character_id, api_base, litellm_params, headers): + def transform_video_get_character_request( + self, character_id, api_base, litellm_params, headers + ): raise NotImplementedError("video get character is not supported for Vertex AI") def transform_video_get_character_response(self, raw_response, logging_obj): raise NotImplementedError("video get character is not supported for Vertex AI") - def transform_video_edit_request(self, prompt, video_id, api_base, litellm_params, headers, extra_body=None): + def transform_video_edit_request( + self, prompt, video_id, api_base, litellm_params, headers, extra_body=None + ): raise NotImplementedError("video edit is not supported for Vertex AI") - def transform_video_edit_response(self, raw_response, logging_obj, custom_llm_provider=None): + def transform_video_edit_response( + self, raw_response, logging_obj, custom_llm_provider=None + ): raise NotImplementedError("video edit is not supported for Vertex AI") - def transform_video_extension_request(self, prompt, video_id, seconds, api_base, litellm_params, headers, extra_body=None): + def transform_video_extension_request( + self, + prompt, + video_id, + seconds, + api_base, + litellm_params, + headers, + extra_body=None, + ): raise NotImplementedError("video extension is not supported for Vertex AI") - def transform_video_extension_response(self, raw_response, logging_obj, custom_llm_provider=None): + def transform_video_extension_response( + self, raw_response, logging_obj, custom_llm_provider=None + ): raise NotImplementedError("video extension is not supported for Vertex AI") def get_error_class( diff --git a/litellm/main.py b/litellm/main.py index 81319bc432..112fef44e5 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -7533,9 +7533,7 @@ def stream_chunk_builder( # noqa: PLR0915 # the final chunk. all_annotations: list = [] for ac in annotation_chunks: - all_annotations.extend( - ac["choices"][0]["delta"]["annotations"] - ) + all_annotations.extend(ac["choices"][0]["delta"]["annotations"]) response["choices"][0]["message"]["annotations"] = all_annotations audio_chunks = [ diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 6786fc3359..181045809f 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -32354,6 +32354,53 @@ "supports_vision": true, "supports_web_search": true }, + "xai/grok-4.20-multi-agent-beta-0309": { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 2000000, + "max_output_tokens": 2000000, + "max_tokens": 2000000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-4.20-beta-0309-reasoning": { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 2000000, + "max_output_tokens": 2000000, + "max_tokens": 2000000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-4.20-beta-0309-non-reasoning": { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 2000000, + "max_output_tokens": 2000000, + "max_tokens": 2000000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "xai/grok-beta": { "input_cost_per_token": 5e-06, "litellm_provider": "xai", diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index af3a715051..3385e7feef 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -677,7 +677,60 @@ async def oauth_authorization_server_mcp( # Alias for standard OpenID discovery @router.get("/.well-known/openid-configuration") async def openid_configuration(request: Request): - return await oauth_authorization_server_mcp(request) + response = await oauth_authorization_server_mcp(request) + + # If MCPJWTSigner is active, augment the discovery doc with JWKS fields so + # MCP servers and gateways (e.g. AWS Bedrock AgentCore Gateway) can resolve + # the signing keys and verify liteLLM-issued tokens. + try: + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( + get_mcp_jwt_signer, + ) + + signer = get_mcp_jwt_signer() + if signer is not None: + request_base_url = get_request_base_url(request) + if isinstance(response, dict): + response = { + **response, + "jwks_uri": f"{request_base_url}/.well-known/jwks.json", + "id_token_signing_alg_values_supported": ["RS256"], + } + except ImportError: + pass + + return response + + +@router.get("/.well-known/jwks.json") +async def jwks_json(request: Request): + """ + JSON Web Key Set endpoint. + + Returns the RSA public key used by MCPJWTSigner to sign outbound MCP tokens. + MCP servers and gateways use this endpoint to verify liteLLM-issued JWTs. + + Returns an empty key set if MCPJWTSigner is not configured. + """ + try: + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( + get_mcp_jwt_signer, + ) + + signer = get_mcp_jwt_signer() + if signer is not None: + return JSONResponse( + content=signer.get_jwks(), + headers={"Cache-Control": f"public, max-age={signer.jwks_max_age}"}, + ) + except ImportError: + pass + + # No signer active — return empty key set; short cache so activation is picked up quickly. + return JSONResponse( + content={"keys": []}, + headers={"Cache-Control": "public, max-age=60"}, + ) # Additional legacy pattern support diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 43fe54fdfb..1e9d5c5a52 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -1908,7 +1908,15 @@ class MCPServerManager: user_api_key_auth: Optional[UserAPIKeyAuth], proxy_logging_obj: ProxyLogging, server: MCPServer, - ): + raw_headers: Optional[Dict[str, str]] = None, + ) -> Dict[str, Any]: + """ + Run pre-call checks and guardrail hooks for an MCP tool call. + + Returns a dict that may contain: + - "arguments": hook-modified tool arguments (only if changed) + - "extra_headers": headers injected by pre_mcp_call guardrail hooks + """ ## check if the tool is allowed or banned for the given server if not self.check_allowed_or_banned_tools(name, server): raise HTTPException( @@ -1932,6 +1940,14 @@ class MCPServerManager: server=server, ) + # Extract incoming Bearer token from raw request headers so + # guardrails like MCPJWTSigner can verify + re-sign it (FR-5). + normalized_raw = {k.lower(): v for k, v in (raw_headers or {}).items()} + incoming_bearer_token: Optional[str] = None + auth_hdr = normalized_raw.get("authorization", "") + if auth_hdr.lower().startswith("bearer "): + incoming_bearer_token = auth_hdr[len("bearer ") :] + pre_hook_kwargs = { "name": name, "arguments": arguments, @@ -1957,6 +1973,7 @@ class MCPServerManager: if user_api_key_auth else None ), + "incoming_bearer_token": incoming_bearer_token, } # Create MCP request object for processing @@ -1969,6 +1986,7 @@ class MCPServerManager: mcp_request_obj, pre_hook_kwargs ) + hook_result: Dict[str, Any] = {} try: # Use standard pre_call_hook modified_data = await proxy_logging_obj.pre_call_hook( @@ -1984,7 +2002,9 @@ class MCPServerManager: ) ) if modified_kwargs.get("arguments") != arguments: - arguments = modified_kwargs["arguments"] + hook_result["arguments"] = modified_kwargs["arguments"] + if modified_kwargs.get("extra_headers"): + hook_result["extra_headers"] = modified_kwargs["extra_headers"] except ( BlockedPiiEntityError, @@ -1995,6 +2015,8 @@ class MCPServerManager: verbose_logger.error(f"Guardrail blocked MCP tool call pre call: {str(e)}") raise e + return hook_result + def _create_during_hook_task( self, name: str, @@ -2047,6 +2069,7 @@ class MCPServerManager: raw_headers: Optional[Dict[str, str]], proxy_logging_obj: Optional[ProxyLogging], host_progress_callback: Optional[Callable] = None, + hook_extra_headers: Optional[Dict[str, str]] = None, ) -> CallToolResult: """ Call a regular MCP tool using the MCP client. @@ -2061,6 +2084,9 @@ class MCPServerManager: oauth2_headers: Optional OAuth2 headers raw_headers: Optional raw headers from the request proxy_logging_obj: Optional ProxyLogging object for hook integration + host_progress_callback: Optional callback for progress updates + hook_extra_headers: Optional headers injected by pre_mcp_call guardrail + hooks. Merged last (highest priority) into outbound request headers. Returns: CallToolResult from the MCP server @@ -2116,6 +2142,31 @@ class MCPServerManager: extra_headers = {} extra_headers.update(mcp_server.static_headers) + if hook_extra_headers: + if extra_headers is None: + extra_headers = {} + if "Authorization" in hook_extra_headers: + if "Authorization" in extra_headers: + verbose_logger.warning( + "MCPServerManager: hook_extra_headers 'Authorization' will overwrite " + "the existing Authorization header from static_headers. " + "The hook JWT will take precedence." + ) + elif server_auth_header is not None: + # server_auth_header is passed separately to _create_mcp_client as + # auth_value. Both will reach the upstream server — warn so admins + # know two Authorization credentials are being sent. + verbose_logger.warning( + "MCPServerManager: hook_extra_headers injects 'Authorization' while " + "server '%s' already has a configured authentication_token. " + "Both credentials will be sent; the hook header is in extra_headers " + "and the server token is in auth_value — the upstream server decides " + "which one wins. Consider unsetting authentication_token if you want " + "the hook JWT to be the sole credential.", + mcp_server.server_name or mcp_server.name, + ) + extra_headers.update(hook_extra_headers) + stdio_env = self._build_stdio_env(mcp_server, raw_headers) client = await self._create_mcp_client( @@ -2201,15 +2252,19 @@ class MCPServerManager: # Allow validation and modification of tool calls before execution # Using standard pre_call_hook ######################################################### + hook_result: Dict[str, Any] = {} if proxy_logging_obj: - await self.pre_call_tool_check( + hook_result = await self.pre_call_tool_check( name=name, arguments=arguments, server_name=server_name, user_api_key_auth=user_api_key_auth, proxy_logging_obj=proxy_logging_obj, server=mcp_server, + raw_headers=raw_headers, ) + if "arguments" in hook_result: + arguments = hook_result["arguments"] # Prepare tasks for during hooks tasks = [] @@ -2227,8 +2282,16 @@ class MCPServerManager: # For OpenAPI servers, call the tool handler directly instead of via MCP client if mcp_server.spec_path: verbose_logger.debug( - f"Calling OpenAPI tool {name} directly via HTTP handler" + "Calling OpenAPI tool %s directly via HTTP handler", name ) + if hook_result.get("extra_headers"): + verbose_logger.warning( + "pre_mcp_call hook returned extra_headers for OpenAPI-backed " + "MCP server '%s' — header injection is not supported for " + "OpenAPI servers; headers will be ignored. Use SSE/HTTP " + "transport to enable hook header injection.", + server_name, + ) tasks.append( asyncio.create_task( self._call_openapi_tool_handler(mcp_server, name, arguments) @@ -2247,6 +2310,7 @@ class MCPServerManager: raw_headers=raw_headers, proxy_logging_obj=proxy_logging_obj, host_progress_callback=host_progress_callback, + hook_extra_headers=hook_result.get("extra_headers"), ) # For OpenAPI tools, await outside the client context diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index ef01f027d6..c0151d47e0 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -903,12 +903,12 @@ if MCP_AVAILABLE: try: client_id, client_secret, scopes = _extract_credentials(request) - _oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = ( - request.oauth2_flow or ( - "client_credentials" - if client_id and client_secret and request.token_url - else None - ) + _oauth2_flow: Optional[ + Literal["client_credentials", "authorization_code"] + ] = request.oauth2_flow or ( + "client_credentials" + if client_id and client_secret and request.token_url + else None ) # client_credentials requires token_url to fetch a token; without it the # incoming auth header would be dropped with nothing to replace it. diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ecbd7314cd..9e86680e35 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2471,6 +2471,9 @@ class UserAPIKeyAuth( Any ] = None # Expanded created_by user when expand=user is used end_user_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None + # Decoded upstream IdP claims (groups, roles, etc.) propagated by JWT auth machinery + # and forwarded into outbound tokens by guardrails such as MCPJWTSigner. + jwt_claims: Optional[Dict] = None model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 0d3c627446..a03e1fb94c 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -680,7 +680,7 @@ def get_customer_user_header_from_mapping(user_id_mapping) -> Optional[list]: if customer_headers_mappings: return customer_headers_mappings - + return None @@ -754,15 +754,11 @@ def get_end_user_id_from_request_body( user_id_str = str(header_value) if user_id_str.strip(): return user_id_str - + elif isinstance(custom_header_name_to_check, str): for header_name, header_value in request_headers.items(): if header_name.lower() == custom_header_name_to_check.lower(): - user_id_str = ( - str(header_value) - if header_value is not None - else "" - ) + user_id_str = str(header_value) if header_value is not None else "" if user_id_str.strip(): return user_id_str diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 376048e7a1..044333ac13 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -685,6 +685,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 do_standard_jwt_auth = True if jwt_handler.litellm_jwtauth.virtual_key_claim_field is not None: # Decode JWT to get claims without running full auth_builder + jwt_claims: Optional[dict] if jwt_handler.litellm_jwtauth.oidc_userinfo_enabled: jwt_claims = await jwt_handler.get_oidc_userinfo(token=api_key) else: @@ -700,6 +701,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 ) if valid_token is not None: api_key = valid_token.token or "" + valid_token.jwt_claims = jwt_claims do_standard_jwt_auth = False # Fall through to virtual key checks @@ -729,6 +731,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 team_membership: Optional[LiteLLM_TeamMembership] = result.get( "team_membership", None ) + jwt_claims = result.get("jwt_claims", None) global_proxy_spend = await get_global_proxy_spend( litellm_proxy_admin_name=litellm_proxy_admin_name, @@ -757,6 +760,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 org_id=org_id, end_user_id=end_user_id, parent_otel_span=parent_otel_span, + jwt_claims=jwt_claims, ) valid_token = UserAPIKeyAuth( @@ -803,6 +807,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 team_metadata=( team_object.metadata if team_object is not None else None ), + jwt_claims=jwt_claims, ) # Check if model has zero cost - if so, skip all budget checks diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 740e63b7f1..38e5229eee 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -537,9 +537,10 @@ async def retrieve_batch( # noqa: PLR0915 ) # Fix: bug_feb14_batch_retrieve_returns_raw_input_file_id - # Resolve raw provider input_file_id to unified ID. + # Resolve raw provider file IDs (input, output, error) to unified IDs. if unified_batch_id: await resolve_input_file_id_to_unified(response, prisma_client) + await resolve_output_file_ids_to_unified(response, prisma_client) ### ALERTING ### asyncio.create_task( diff --git a/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/__init__.py new file mode 100644 index 0000000000..abea9014a1 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/__init__.py @@ -0,0 +1,84 @@ +"""MCP JWT Signer guardrail — built-in LiteLLM guardrail for zero trust MCP auth.""" + +from typing import TYPE_CHECKING + +from litellm.types.guardrails import SupportedGuardrailIntegrations + +from .mcp_jwt_signer import MCPJWTSigner, get_mcp_jwt_signer + +if TYPE_CHECKING: + from litellm.types.guardrails import Guardrail, LitellmParams + + +def initialize_guardrail( + litellm_params: "LitellmParams", guardrail: "Guardrail" +) -> MCPJWTSigner: + import litellm + + guardrail_name = guardrail.get("guardrail_name") + if not guardrail_name: + raise ValueError("MCPJWTSigner guardrail requires a guardrail_name") + + mode = litellm_params.mode + if mode != "pre_mcp_call": + raise ValueError( + f"MCPJWTSigner guardrail '{guardrail_name}' has mode='{mode}' but must use " + "mode='pre_mcp_call'. JWT injection only fires for MCP tool calls." + ) + + optional_params = getattr(litellm_params, "optional_params", None) + + def _get(key): # type: ignore[no-untyped-def] + if optional_params is not None: + v = getattr(optional_params, key, None) + if v is not None: + return v + return getattr(litellm_params, key, None) + + signer = MCPJWTSigner( + guardrail_name=guardrail_name, + event_hook=litellm_params.mode, + default_on=litellm_params.default_on, + # Core signing + issuer=_get("issuer"), + audience=_get("audience"), + ttl_seconds=_get("ttl_seconds"), + # FR-5: verify + re-sign + access_token_discovery_uri=_get("access_token_discovery_uri"), + token_introspection_endpoint=_get("token_introspection_endpoint"), + verify_issuer=_get("verify_issuer"), + verify_audience=_get("verify_audience"), + # FR-12: end-user identity mapping + end_user_claim_sources=_get("end_user_claim_sources"), + # FR-13: claim operations + add_claims=_get("add_claims"), + set_claims=_get("set_claims"), + remove_claims=_get("remove_claims"), + # FR-14: two-token model + channel_token_audience=_get("channel_token_audience"), + channel_token_ttl=_get("channel_token_ttl"), + # FR-15: incoming claim validation + required_claims=_get("required_claims"), + optional_claims=_get("optional_claims"), + # FR-9: debug headers + debug_headers=_get("debug_headers") or False, + # FR-10: configurable scopes + allowed_scopes=_get("allowed_scopes"), + ) + litellm.logging_callback_manager.add_litellm_callback(signer) + return signer + + +guardrail_initializer_registry = { + SupportedGuardrailIntegrations.MCP_JWT_SIGNER.value: initialize_guardrail, +} + +guardrail_class_registry = { + SupportedGuardrailIntegrations.MCP_JWT_SIGNER.value: MCPJWTSigner, +} + +__all__ = [ + "MCPJWTSigner", + "initialize_guardrail", + "get_mcp_jwt_signer", +] diff --git a/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py b/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py new file mode 100644 index 0000000000..5502076829 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/mcp_jwt_signer/mcp_jwt_signer.py @@ -0,0 +1,891 @@ +""" +MCPJWTSigner — Built-in LiteLLM guardrail for zero trust MCP authentication. + +Signs outbound MCP requests with a LiteLLM-issued RS256 JWT so that MCP servers +can trust a single signing authority (liteLLM) instead of every upstream IdP. + +Usage in config.yaml: + + guardrails: + - guardrail_name: "mcp-jwt-signer" + litellm_params: + guardrail: mcp_jwt_signer + mode: "pre_mcp_call" + default_on: true + + # Core signing config + issuer: "https://my-litellm.example.com" # optional + audience: "mcp" # optional + ttl_seconds: 300 # optional + + # FR-5: Verify + re-sign — validate incoming Bearer token before signing + access_token_discovery_uri: "https://idp.example.com/.well-known/openid-configuration" + token_introspection_endpoint: "https://idp.example.com/introspect" # opaque tokens + verify_issuer: "https://idp.example.com" # expected iss in incoming JWT + verify_audience: "api://my-app" # expected aud in incoming JWT + + # FR-12: End-user identity mapping — ordered resolution chain + # Supported: token:, litellm:user_id, litellm:email, + # litellm:end_user_id, litellm:team_id + end_user_claim_sources: + - "token:sub" + - "token:email" + - "litellm:user_id" + + # FR-13: Claim operations + add_claims: # add if key not already present in the JWT + deployment_id: "prod-001" + set_claims: # always set (overrides computed value) + env: "production" + remove_claims: # remove from final JWT + - "nbf" + + # FR-14: Two-token model — issue a second JWT for the MCP transport channel + channel_token_audience: "bedrock-gateway" + channel_token_ttl: 60 + + # FR-15: Incoming claim validation — enforce required IdP claims + required_claims: + - "sub" + - "email" + optional_claims: # pass through from jwt_claims into outbound JWT + - "groups" + - "roles" + + # FR-9: Debug headers + debug_headers: false # emit x-litellm-mcp-debug header when true + + # FR-10: Configurable scopes — explicit list replaces auto-generation + allowed_scopes: + - "mcp:tools/call" + - "mcp:tools/list" + +MCP servers verify tokens via: + GET /.well-known/openid-configuration → { jwks_uri: ".../.well-known/jwks.json" } + GET /.well-known/jwks.json → RSA public key in JWKS format + +Optionally set MCP_JWT_SIGNING_KEY env var (PEM string or file:///path) to use +your own RSA keypair. If unset, an RSA-2048 keypair is auto-generated at startup. +""" + +import base64 +import hashlib +import os +import re +import time +from typing import Any, Dict, List, Optional, Union + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey + +from litellm._logging import verbose_proxy_logger +from litellm.caching import DualCache +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) +from litellm.proxy._types import UserAPIKeyAuth +from litellm.types.utils import CallTypesLiteral + +# Module-level singleton for the JWKS discovery endpoint to access. +_mcp_jwt_signer_instance: Optional["MCPJWTSigner"] = None + +# Simple in-memory JWKS cache: keyed by JWKS URI → (keys_list, fetched_at). +_jwks_cache: Dict[str, tuple] = {} +_JWKS_CACHE_TTL = 3600 # 1 hour + + +def get_mcp_jwt_signer() -> Optional["MCPJWTSigner"]: + """Return the active MCPJWTSigner singleton, or None if not initialized.""" + return _mcp_jwt_signer_instance + + +def _load_private_key_from_env(env_var: str) -> RSAPrivateKey: + """Load an RSA private key from an env var (PEM string or file:// path).""" + key_material = os.environ.get(env_var, "") + if not key_material: + raise ValueError( + f"MCPJWTSigner: environment variable '{env_var}' is set but empty." + ) + if key_material.startswith("file://"): + path = key_material[len("file://") :] + with open(path, "rb") as f: + key_bytes = f.read() + else: + key_bytes = key_material.encode("utf-8") + return serialization.load_pem_private_key(key_bytes, password=None) # type: ignore[return-value] + + +def _generate_rsa_key_pair() -> RSAPrivateKey: + """Generate a new RSA-2048 private key.""" + return rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + +def _int_to_base64url(n: int) -> str: + """Encode an integer as a base64url string (no padding).""" + byte_length = (n.bit_length() + 7) // 8 + return ( + base64.urlsafe_b64encode(n.to_bytes(byte_length, byteorder="big")) + .rstrip(b"=") + .decode("ascii") + ) + + +def _compute_kid(public_key: Any) -> str: + """Derive a key ID from the public key's DER encoding (SHA-256, first 16 hex chars).""" + der_bytes = public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return hashlib.sha256(der_bytes).hexdigest()[:16] + + +async def _fetch_jwks(jwks_uri: str) -> List[Dict[str, Any]]: + """ + Fetch and cache a JWKS from the given URI. + + Results are cached for _JWKS_CACHE_TTL seconds to avoid hammering the IdP. + """ + now = time.time() + cached = _jwks_cache.get(jwks_uri) + if cached is not None: + keys, fetched_at = cached + if now - fetched_at < _JWKS_CACHE_TTL: + return keys # type: ignore[return-value] + + from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, + httpxSpecialProvider, + ) + + client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) + resp = await client.get(jwks_uri, headers={"Accept": "application/json"}) + resp.raise_for_status() + keys = resp.json().get("keys", []) + _jwks_cache[jwks_uri] = (keys, now) + return keys # type: ignore[return-value] + + +async def _fetch_oidc_discovery(discovery_uri: str) -> Dict[str, Any]: + """Fetch an OIDC discovery document and return its parsed JSON.""" + from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, + httpxSpecialProvider, + ) + + client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) + resp = await client.get(discovery_uri, headers={"Accept": "application/json"}) + resp.raise_for_status() + return resp.json() # type: ignore[return-value] + + +class MCPJWTSigner(CustomGuardrail): + """ + Built-in LiteLLM guardrail that signs outbound MCP requests with a + LiteLLM-issued RS256 JWT, enabling zero trust authentication. + + MCP servers verify tokens using liteLLM's OIDC discovery endpoint and + JWKS endpoint rather than trusting each upstream IdP directly. + + The signed JWT carries: + - iss: LiteLLM issuer identifier + - aud: MCP audience (configurable) + - sub: End-user identity (resolved via end_user_claim_sources, RFC 8693) + - act: Actor/agent identity (team_id or org_id, RFC 8693 delegation) + - scope: Tool-level access scopes (configurable via allowed_scopes) + - iat, exp, nbf: Standard timing claims + + Feature set: + FR-5: Verify + re-sign (access_token_discovery_uri, token_introspection_endpoint) + FR-9: Debug headers (debug_headers) + FR-10: Configurable scopes (allowed_scopes) + FR-12: Configurable end-user identity mapping (end_user_claim_sources) + FR-13: Claim operations (add_claims, set_claims, remove_claims) + FR-14: Two-token model (channel_token_audience, channel_token_ttl) + FR-15: Incoming claim validation (required_claims, optional_claims) + """ + + ALGORITHM = "RS256" + DEFAULT_TTL = 300 + DEFAULT_AUDIENCE = "mcp" + SIGNING_KEY_ENV = "MCP_JWT_SIGNING_KEY" + + def __init__( + self, + # Core signing config + issuer: Optional[str] = None, + audience: Optional[str] = None, + ttl_seconds: Optional[int] = None, + # FR-5: Verify + re-sign + access_token_discovery_uri: Optional[str] = None, + token_introspection_endpoint: Optional[str] = None, + verify_issuer: Optional[str] = None, + verify_audience: Optional[str] = None, + # FR-12: End-user identity mapping + end_user_claim_sources: Optional[List[str]] = None, + # FR-13: Claim operations + add_claims: Optional[Dict[str, Any]] = None, + set_claims: Optional[Dict[str, Any]] = None, + remove_claims: Optional[List[str]] = None, + # FR-14: Two-token model + channel_token_audience: Optional[str] = None, + channel_token_ttl: Optional[int] = None, + # FR-15: Incoming claim validation + required_claims: Optional[List[str]] = None, + optional_claims: Optional[List[str]] = None, + # FR-9: Debug headers + debug_headers: bool = False, + # FR-10: Configurable scopes + allowed_scopes: Optional[List[str]] = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + + # --- Signing key setup --- + key_material = os.environ.get(self.SIGNING_KEY_ENV) + if key_material: + self._private_key = _load_private_key_from_env(self.SIGNING_KEY_ENV) + self._persistent_key: bool = True + verbose_proxy_logger.info( + "MCPJWTSigner: loaded RSA key from env var %s", self.SIGNING_KEY_ENV + ) + else: + self._private_key = _generate_rsa_key_pair() + self._persistent_key = False + verbose_proxy_logger.info( + "MCPJWTSigner: auto-generated RSA-2048 keypair (set %s to use your own key)", + self.SIGNING_KEY_ENV, + ) + + self._public_key = self._private_key.public_key() + self._kid = _compute_kid(self._public_key) + + # --- Core config --- + self.issuer: str = ( + issuer + or os.environ.get("MCP_JWT_ISSUER") + or os.environ.get("LITELLM_EXTERNAL_URL") + or "litellm" + ) + self.audience: str = ( + audience or os.environ.get("MCP_JWT_AUDIENCE") or self.DEFAULT_AUDIENCE + ) + resolved_ttl = int( + ttl_seconds + if ttl_seconds is not None + else os.environ.get("MCP_JWT_TTL_SECONDS", str(self.DEFAULT_TTL)) + ) + if resolved_ttl <= 0: + raise ValueError( + f"MCPJWTSigner: ttl_seconds must be > 0, got {resolved_ttl}" + ) + self.ttl_seconds: int = resolved_ttl + + # --- FR-5: Verify + re-sign --- + self.access_token_discovery_uri: Optional[str] = access_token_discovery_uri + self.token_introspection_endpoint: Optional[str] = token_introspection_endpoint + self.verify_issuer: Optional[str] = verify_issuer + self.verify_audience: Optional[str] = verify_audience + # Cached OIDC discovery document (fetched lazily, TTL = 24 h) + self._oidc_discovery_doc: Optional[Dict[str, Any]] = None + self._oidc_discovery_fetched_at: float = 0.0 + + # --- FR-12: End-user identity mapping --- + # Default chain: try incoming JWT sub, fall back to litellm user_id + self.end_user_claim_sources: List[str] = end_user_claim_sources or [ + "token:sub", + "litellm:user_id", + ] + + # --- FR-13: Claim operations --- + self.add_claims: Dict[str, Any] = add_claims or {} + self.set_claims: Dict[str, Any] = set_claims or {} + self.remove_claims: List[str] = remove_claims or [] + + # --- FR-14: Two-token model --- + self.channel_token_audience: Optional[str] = channel_token_audience + self.channel_token_ttl: int = ( + channel_token_ttl if channel_token_ttl is not None else self.ttl_seconds + ) + + # --- FR-15: Incoming claim validation --- + self.required_claims: List[str] = required_claims or [] + self.optional_claims: List[str] = optional_claims or [] + + # --- FR-9: Debug headers --- + self.debug_headers: bool = debug_headers + + # --- FR-10: Configurable scopes --- + self.allowed_scopes: Optional[List[str]] = allowed_scopes + + # Register singleton for JWKS/OIDC discovery endpoints. + global _mcp_jwt_signer_instance + if _mcp_jwt_signer_instance is not None: + verbose_proxy_logger.warning( + "MCPJWTSigner: replacing existing singleton — previously issued tokens " + "signed with the old key will fail JWKS verification. " + "Avoid configuring multiple mcp_jwt_signer guardrails." + ) + _mcp_jwt_signer_instance = self + + verbose_proxy_logger.info( + "MCPJWTSigner initialized: issuer=%s audience=%s ttl=%ds kid=%s " + "verify=%s channel_token=%s debug=%s", + self.issuer, + self.audience, + self.ttl_seconds, + self._kid, + bool(self.access_token_discovery_uri), + bool(self.channel_token_audience), + self.debug_headers, + ) + + # ------------------------------------------------------------------ + # Public helpers (used by /.well-known/jwks.json endpoint) + # ------------------------------------------------------------------ + + @property + def jwks_max_age(self) -> int: + """ + Recommended Cache-Control max-age for the JWKS response (seconds). + + 1 hour for persistent keys; 5 minutes for auto-generated keys so MCP + servers re-fetch quickly after a proxy restart. + """ + return 3600 if self._persistent_key else 300 + + def get_jwks(self) -> Dict[str, Any]: + """ + Return the JWKS for the RSA public key. + Used by GET /.well-known/jwks.json so MCP servers can verify tokens. + """ + public_numbers = self._public_key.public_numbers() + return { + "keys": [ + { + "kty": "RSA", + "alg": self.ALGORITHM, + "use": "sig", + "kid": self._kid, + "n": _int_to_base64url(public_numbers.n), + "e": _int_to_base64url(public_numbers.e), + } + ] + } + + # ------------------------------------------------------------------ + # FR-5: Verify + re-sign helpers + # ------------------------------------------------------------------ + + # 24-hour TTL for the OIDC discovery doc — long enough to avoid hammering + # the IdP, short enough to pick up jwks_uri changes after key rotation. + _OIDC_DISCOVERY_TTL = 86400 + + async def _get_oidc_discovery(self) -> Dict[str, Any]: + """Fetch and cache the OIDC discovery document with a 24-hour TTL. + + Only caches when the doc contains a 'jwks_uri' so that a transient or + malformed response doesn't permanently disable JWT verification. + """ + now = time.time() + cache_expired = ( + now - self._oidc_discovery_fetched_at + ) >= self._OIDC_DISCOVERY_TTL + if ( + self._oidc_discovery_doc is None or cache_expired + ) and self.access_token_discovery_uri: + doc = await _fetch_oidc_discovery(self.access_token_discovery_uri) + if "jwks_uri" in doc: + self._oidc_discovery_doc = doc + self._oidc_discovery_fetched_at = now + else: + return doc + return self._oidc_discovery_doc or {} + + async def _verify_incoming_jwt(self, raw_token: str) -> Dict[str, Any]: + """ + Verify an incoming Bearer JWT against the configured IdP's JWKS. + + Returns the verified payload claims dict. + Raises jwt.PyJWTError (or subclass) if verification fails. + """ + discovery = await self._get_oidc_discovery() + jwks_uri = discovery.get("jwks_uri") + if not jwks_uri: + raise ValueError( + "MCPJWTSigner: access_token_discovery_uri discovery document " + f"at {self.access_token_discovery_uri!r} has no 'jwks_uri'." + ) + + jwks_keys = await _fetch_jwks(jwks_uri) + + # Only read `kid` from the unverified header — never `alg`. + # Reading `alg` from an attacker-controlled header enables algorithm + # confusion attacks (e.g. alg:none, HS256 with the public key as secret). + # The algorithm is determined from the JWKS key entry instead. + unverified_header = jwt.get_unverified_header(raw_token) + kid = unverified_header.get("kid") + + # Build a JWKS object and pick the matching key. + # PyJWT's PyJWKSet handles key-type parsing and kid matching correctly. + from jwt import PyJWKSet + + try: + jwks_set = PyJWKSet.from_dict({"keys": jwks_keys}) + except Exception as exc: + raise jwt.exceptions.PyJWKSetError( # type: ignore[attr-defined] + f"Failed to parse JWKS from {jwks_uri!r}: {exc}" + ) from exc + + signing_jwk = None + for jwk_obj in jwks_set.keys: + if not kid or jwk_obj.key_id == kid: + signing_jwk = jwk_obj + break + + if signing_jwk is None: + raise jwt.exceptions.PyJWKSetError( # type: ignore[attr-defined] + f"No JWKS key matching kid={kid!r} at {jwks_uri!r}" + ) + + # Use the algorithm declared by the JWKS key entry, not the token header. + # PyJWT populates algorithm_name from the key's `alg` field; when absent + # it infers from the key type (RSAPublicKey → RS256). + alg = getattr(signing_jwk, "algorithm_name", None) or "RS256" + + decode_options: Dict[str, Any] = {"verify_exp": True} + decode_kwargs: Dict[str, Any] = { + "algorithms": [alg], + "options": decode_options, + } + if self.verify_audience: + decode_kwargs["audience"] = self.verify_audience + else: + decode_options["verify_aud"] = False + + if self.verify_issuer: + decode_kwargs["issuer"] = self.verify_issuer + + payload: Dict[str, Any] = jwt.decode( + raw_token, signing_jwk.key, **decode_kwargs + ) + return payload + + async def _introspect_opaque_token(self, token: str) -> Dict[str, Any]: + """ + Perform RFC 7662 token introspection for opaque (non-JWT) tokens. + + Returns the introspection response dict. Raises on HTTP error or + inactive token. + """ + if not self.token_introspection_endpoint: + raise ValueError( + "MCPJWTSigner: token_introspection_endpoint is required for " + "opaque token verification but is not configured." + ) + + from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, + httpxSpecialProvider, + ) + + client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check) + resp = await client.post( + self.token_introspection_endpoint, + data={"token": token}, + headers={"Accept": "application/json"}, + ) + resp.raise_for_status() + result: Dict[str, Any] = resp.json() + if not result.get("active", False): + raise jwt.exceptions.ExpiredSignatureError( # type: ignore[attr-defined] + "MCPJWTSigner: incoming token is inactive (introspection returned active=false)" + ) + return result + + # ------------------------------------------------------------------ + # FR-15: Incoming claim validation + # ------------------------------------------------------------------ + + def _validate_required_claims( + self, + jwt_claims: Optional[Dict[str, Any]], + ) -> None: + """ + Raise HTTP 403 if any required_claims are absent from the verified + incoming token claims. + """ + if not self.required_claims: + return + + from fastapi import HTTPException + + missing = [c for c in self.required_claims if not (jwt_claims or {}).get(c)] + if missing: + raise HTTPException( + status_code=403, + detail={ + "error": ( + f"MCPJWTSigner: incoming token is missing required claims: " + f"{missing}. Configure the IdP to include these claims." + ) + }, + ) + + # ------------------------------------------------------------------ + # FR-12: End-user identity mapping + # ------------------------------------------------------------------ + + def _resolve_end_user_identity( + self, + user_api_key_dict: UserAPIKeyAuth, + jwt_claims: Optional[Dict[str, Any]], + ) -> str: + """ + Resolve the outbound JWT 'sub' using the ordered end_user_claim_sources list. + + Supported source prefixes: + token: — from verified incoming JWT / introspection claims + litellm:user_id — from UserAPIKeyAuth.user_id + litellm:email — from UserAPIKeyAuth.user_email + litellm:end_user_id — from UserAPIKeyAuth.end_user_id + litellm:team_id — from UserAPIKeyAuth.team_id + + Falls back to a stable hash of the API token for service-account callers. + """ + for source in self.end_user_claim_sources: + value: Optional[str] = None + + if source.startswith("token:"): + claim_name = source[len("token:") :] + raw = (jwt_claims or {}).get(claim_name) + value = str(raw) if raw else None + + elif source == "litellm:user_id": + uid = getattr(user_api_key_dict, "user_id", None) + value = str(uid) if uid else None + + elif source == "litellm:email": + email = getattr(user_api_key_dict, "user_email", None) + value = str(email) if email else None + + elif source == "litellm:end_user_id": + eid = getattr(user_api_key_dict, "end_user_id", None) + value = str(eid) if eid else None + + elif source == "litellm:team_id": + tid = getattr(user_api_key_dict, "team_id", None) + value = str(tid) if tid else None + + else: + verbose_proxy_logger.warning( + "MCPJWTSigner: unknown end_user_claim_source %r — skipping", source + ) + continue + + if value: + return value + + # Final fallback for service accounts with no user identity + token = getattr(user_api_key_dict, "token", None) or getattr( + user_api_key_dict, "api_key", None + ) + if token: + return "apikey:" + hashlib.sha256(str(token).encode()).hexdigest()[:16] + return "litellm-proxy" + + # ------------------------------------------------------------------ + # FR-10: Scope building + # ------------------------------------------------------------------ + + def _build_scope(self, raw_tool_name: str) -> str: + """ + Build the JWT scope string. + + When allowed_scopes is configured: join them verbatim. + Otherwise auto-generate minimal, least-privilege scopes: + - Tool call → mcp:tools/call mcp:tools/:call + - No tool → mcp:tools/call mcp:tools/list + + NOTE: tools/list is intentionally NOT granted on tool-call JWTs to + prevent callers from enumerating tools they didn't ask to use. + """ + if self.allowed_scopes is not None: + return " ".join(self.allowed_scopes) + + tool_name = ( + re.sub(r"[^a-zA-Z0-9_\-]", "_", raw_tool_name) if raw_tool_name else "" + ) + if tool_name: + scopes = ["mcp:tools/call", f"mcp:tools/{tool_name}:call"] + else: + scopes = ["mcp:tools/call", "mcp:tools/list"] + return " ".join(scopes) + + # ------------------------------------------------------------------ + # FR-13: Claim operations + # ------------------------------------------------------------------ + + def _apply_claim_operations(self, claims: Dict[str, Any]) -> Dict[str, Any]: + """Apply add_claims, set_claims, and remove_claims to the claim dict.""" + # add_claims: insert only when key is absent + for k, v in self.add_claims.items(): + if k not in claims: + claims[k] = v + + # set_claims: always override (highest priority) + claims = {**claims, **self.set_claims} + + # remove_claims: delete listed keys + for k in self.remove_claims: + claims.pop(k, None) + + return claims + + # ------------------------------------------------------------------ + # FR-15: optional_claims passthrough + # ------------------------------------------------------------------ + + def _passthrough_optional_claims( + self, + claims: Dict[str, Any], + jwt_claims: Optional[Dict[str, Any]], + ) -> Dict[str, Any]: + """Forward optional_claims from verified incoming token into the outbound JWT.""" + if not self.optional_claims or not jwt_claims: + return claims + for claim in self.optional_claims: + if claim in jwt_claims and claim not in claims: + claims[claim] = jwt_claims[claim] + return claims + + # ------------------------------------------------------------------ + # Core JWT builder + # ------------------------------------------------------------------ + + def _build_claims( + self, + user_api_key_dict: UserAPIKeyAuth, + data: dict, + jwt_claims: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Build JWT claims for the outbound MCP access token. + + Args: + user_api_key_dict: LiteLLM auth context for the current request. + data: Pre-call hook data dict (contains mcp_tool_name etc.). + jwt_claims: Verified incoming IdP claims (FR-5), or LiteLLM-decoded + jwt_claims if available. None for pure API-key requests. + """ + now = int(time.time()) + claims: Dict[str, Any] = { + "iss": self.issuer, + "aud": self.audience, + "iat": now, + "exp": now + self.ttl_seconds, + "nbf": now, + } + + # sub — resolved via ordered claim sources (FR-12) + claims["sub"] = self._resolve_end_user_identity(user_api_key_dict, jwt_claims) + + # email passthrough when available from LiteLLM context + user_email = getattr(user_api_key_dict, "user_email", None) + if user_email: + claims["email"] = user_email + + # act — RFC 8693 delegation claim (team/org context) + team_id = getattr(user_api_key_dict, "team_id", None) + org_id = getattr(user_api_key_dict, "org_id", None) + act_sub = team_id or org_id or "litellm-proxy" + claims["act"] = {"sub": act_sub} + + # end_user_id when set separately from user_id + end_user_id = getattr(user_api_key_dict, "end_user_id", None) + if end_user_id: + claims["end_user_id"] = end_user_id + + # scope (FR-10) + raw_tool_name: str = data.get("mcp_tool_name", "") + claims["scope"] = self._build_scope(raw_tool_name) + + # optional_claims passthrough (FR-15) + claims = self._passthrough_optional_claims(claims, jwt_claims) + + # Claim operations — applied last so admin overrides take effect (FR-13) + claims = self._apply_claim_operations(claims) + + return claims + + def _build_channel_token_claims( + self, + base_claims: Dict[str, Any], + ) -> Dict[str, Any]: + """ + Build claims for the channel token (FR-14 two-token model). + + Inherits sub/act/scope from the access token but uses a separate + audience and TTL so the transport layer and resource layer receive + purpose-bound credentials. + """ + now = int(time.time()) + return { + **base_claims, + "aud": self.channel_token_audience, + "iat": now, + "exp": now + self.channel_token_ttl, + "nbf": now, + } + + # ------------------------------------------------------------------ + # FR-9: Debug header + # ------------------------------------------------------------------ + + @staticmethod + def _build_debug_header(claims: Dict[str, Any], kid: str) -> str: + """ + Build the x-litellm-mcp-debug header value. + + Format: v=1; kid=; sub=; iss=; exp=; scope= + Scope is truncated to 80 chars for header safety. + """ + sub = claims.get("sub", "") + iss = claims.get("iss", "") + exp = claims.get("exp", 0) + scope = claims.get("scope", "") + if len(scope) > 80: + scope = scope[:77] + "..." + return f"v=1; kid={kid}; sub={sub}; iss={iss}; exp={exp}; scope={scope}" + + # ------------------------------------------------------------------ + # Guardrail hook + # ------------------------------------------------------------------ + + @log_guardrail_information + async def async_pre_call_hook( + self, + user_api_key_dict: UserAPIKeyAuth, + cache: DualCache, + data: dict, + call_type: CallTypesLiteral, + ) -> Optional[Union[Exception, str, dict]]: + """ + Verifies the incoming token (when configured), validates required claims, + then signs an outbound JWT and injects it as the Authorization header. + + All non-MCP call types pass through unchanged. + """ + if call_type != "call_mcp_tool": + return data + + # ------------------------------------------------------------------ + # FR-5: Verify incoming token before re-signing + # ------------------------------------------------------------------ + jwt_claims: Optional[Dict[str, Any]] = None + raw_token: Optional[str] = data.get("incoming_bearer_token") + + if self.access_token_discovery_uri and raw_token: + # Three-dot pattern → JWT; otherwise opaque. + is_jwt = raw_token.count(".") == 2 + try: + if is_jwt: + jwt_claims = await self._verify_incoming_jwt(raw_token) + elif self.token_introspection_endpoint: + jwt_claims = await self._introspect_opaque_token(raw_token) + else: + verbose_proxy_logger.warning( + "MCPJWTSigner: access_token_discovery_uri is set but the " + "incoming token appears to be opaque and no " + "token_introspection_endpoint is configured. " + "Proceeding without incoming token verification." + ) + except Exception as exc: + verbose_proxy_logger.error( + "MCPJWTSigner: incoming token verification failed: %s", exc + ) + from fastapi import HTTPException + + raise HTTPException( + status_code=401, + detail={ + "error": ( + f"MCPJWTSigner: incoming token verification failed: {exc}" + ) + }, + ) + elif not raw_token and self.access_token_discovery_uri: + verbose_proxy_logger.debug( + "MCPJWTSigner: access_token_discovery_uri configured but no Bearer " + "token found in request (API-key auth request — skipping verification)." + ) + + # Fall back to LiteLLM-decoded JWT claims (available when proxy uses JWT auth). + if jwt_claims is None: + jwt_claims = getattr(user_api_key_dict, "jwt_claims", None) + + # ------------------------------------------------------------------ + # FR-15: Validate required claims + # ------------------------------------------------------------------ + self._validate_required_claims(jwt_claims) + + # ------------------------------------------------------------------ + # Build outbound access token + # ------------------------------------------------------------------ + claims = self._build_claims(user_api_key_dict, data, jwt_claims) + + signed_token = jwt.encode( + claims, + self._private_key, + algorithm=self.ALGORITHM, + headers={"kid": self._kid}, + ) + + # Merge into existing extra_headers — a prior guardrail in the chain may + # have already injected tracing headers or correlation IDs. + existing_headers: Dict[str, str] = data.get("extra_headers") or {} + new_headers: Dict[str, str] = { + **existing_headers, + "Authorization": f"Bearer {signed_token}", + } + + # ------------------------------------------------------------------ + # FR-14: Two-token model — channel token + # ------------------------------------------------------------------ + if self.channel_token_audience: + channel_claims = self._build_channel_token_claims(claims) + channel_token = jwt.encode( + channel_claims, + self._private_key, + algorithm=self.ALGORITHM, + headers={"kid": self._kid}, + ) + new_headers["x-mcp-channel-token"] = f"Bearer {channel_token}" + + # ------------------------------------------------------------------ + # FR-9: Debug header + # ------------------------------------------------------------------ + if self.debug_headers: + new_headers["x-litellm-mcp-debug"] = self._build_debug_header( + claims, self._kid + ) + + data["extra_headers"] = new_headers + + verbose_proxy_logger.debug( + "MCPJWTSigner: signed JWT sub=%s act=%s tool=%s exp=%d " + "verified=%s channel=%s", + claims.get("sub"), + claims.get("act", {}).get("sub"), + data.get("mcp_tool_name"), + claims["exp"], + jwt_claims is not None, + bool(self.channel_token_audience), + ) + + return data diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 8a71b8d4b5..ca8c345f46 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2142,8 +2142,7 @@ async def _resolve_org_filter_for_user_search( member_org_ids: List[str] = [] if caller_user is not None: member_org_ids = [ - m.organization_id - for m in (caller_user.organization_memberships or []) + m.organization_id for m in (caller_user.organization_memberships or []) ] if member_org_ids: diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index e09d7607eb..1016e6b149 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -1863,16 +1863,10 @@ async def _validate_update_key_data( user_api_key_cache: Any, ) -> None: """Validate permissions and constraints for key update.""" - _is_proxy_admin = ( - user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value - ) + _is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value # Prevent non-admin from removing user_id (setting to empty string) (LIT-1884) - if ( - data.user_id is not None - and data.user_id == "" - and not _is_proxy_admin - ): + if data.user_id is not None and data.user_id == "" and not _is_proxy_admin: raise HTTPException( status_code=403, detail="Non-admin users cannot remove the user_id from a key.", diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index d83ceb1b09..ceb3d5d277 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -857,7 +857,13 @@ async def new_team( # noqa: PLR0915 # Apply defaults from litellm.default_team_params for any fields # not explicitly provided in the request. - for field in ("max_budget", "budget_duration", "tpm_limit", "rpm_limit", "team_member_permissions"): + for field in ( + "max_budget", + "budget_duration", + "tpm_limit", + "rpm_limit", + "team_member_permissions", + ): if getattr(data, field, None) is None: default_value = _get_default_team_param(field) if default_value is not None: diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 49f1753533..25ad1b25aa 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -857,7 +857,10 @@ async def update_batch_in_database( # If the batch_processed column doesn't exist (old schema), # retry without it so the status update still succeeds. err_str = str(col_err).lower() - if "batch_processed" in err_str and update_data.get("batch_processed") is not None: + if ( + "batch_processed" in err_str + and update_data.get("batch_processed") is not None + ): verbose_proxy_logger.warning( f"batch_processed column not found, retrying update without it: {col_err}" ) diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index e5a34ae8bd..97d5de0d53 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -468,6 +468,12 @@ class ProxyInitializationHelpers: type=str, help="Path to the logging configuration file", ) +@click.option( + "--setup", + is_flag=True, + default=False, + help="Run the interactive setup wizard to configure providers and generate a config file", +) @click.option( "--version", "-v", @@ -598,6 +604,7 @@ def run_server( # noqa: PLR0915 num_requests, use_queue, health, + setup, version, run_gunicorn, run_hypercorn, @@ -611,6 +618,12 @@ def run_server( # noqa: PLR0915 max_requests_before_restart, enforce_prisma_migration_check: bool, ): + if setup: + from litellm.setup_wizard import run_setup_wizard + + run_setup_wizard() + return + args = locals() if local: from proxy_server import ( @@ -904,7 +917,7 @@ def run_server( # noqa: PLR0915 # Auto-create PROMETHEUS_MULTIPROC_DIR for multi-worker setups ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir( num_workers=num_workers, - litellm_settings=litellm_settings if config else None, + litellm_settings=litellm_settings if config else None, # type: ignore[possibly-unbound] ) # --- SEPARATE HEALTH APP LOGIC --- diff --git a/litellm/proxy/response_polling/background_streaming.py b/litellm/proxy/response_polling/background_streaming.py index b4d51814e5..7583f30eb2 100644 --- a/litellm/proxy/response_polling/background_streaming.py +++ b/litellm/proxy/response_polling/background_streaming.py @@ -115,7 +115,9 @@ async def background_streaming_task( # noqa: PLR0915 UPDATE_INTERVAL = 0.150 # 150ms batching interval # Track the terminal event from the stream (may not be "completed") - terminal_status: Optional[ResponsesAPIStatus] = None # Will be set by response.completed/failed/incomplete/cancelled + terminal_status: Optional[ + ResponsesAPIStatus + ] = None # Will be set by response.completed/failed/incomplete/cancelled terminal_error = None _event_to_status = { "response.completed": "completed", @@ -259,7 +261,10 @@ async def background_streaming_task( # noqa: PLR0915 ) # Extract error for failed and incomplete responses - if event_type == "response.failed" or event_type == "response.incomplete": + if ( + event_type == "response.failed" + or event_type == "response.incomplete" + ): terminal_error = response_data.get("error") # Core response fields diff --git a/litellm/proxy/spend_tracking/vantage_endpoints.py b/litellm/proxy/spend_tracking/vantage_endpoints.py index 14c0ebcb54..7d8fbf7461 100644 --- a/litellm/proxy/spend_tracking/vantage_endpoints.py +++ b/litellm/proxy/spend_tracking/vantage_endpoints.py @@ -40,9 +40,7 @@ def _get_registered_vantage_logger(): return None -async def _set_vantage_settings( - api_key: str, integration_token: str, base_url: str -): +async def _set_vantage_settings(api_key: str, integration_token: str, base_url: str): """Store Vantage settings in the database with encrypted API key.""" from litellm.proxy.proxy_server import prisma_client @@ -341,9 +339,7 @@ async def init_vantage_settings( except HTTPException: raise except Exception as e: - verbose_proxy_logger.error( - f"Error initializing Vantage settings: {str(e)}" - ) + verbose_proxy_logger.error(f"Error initializing Vantage settings: {str(e)}") raise HTTPException( status_code=500, detail={"error": f"Failed to initialize Vantage settings: {str(e)}"}, @@ -395,7 +391,8 @@ async def vantage_dry_run_export( """Cast Decimal columns to Float64 so .to_dicts() produces JSON-serializable float values instead of decimal.Decimal.""" decimal_cols = [ - col for col, dtype in zip(frame.columns, frame.dtypes) + col + for col, dtype in zip(frame.columns, frame.dtypes) if isinstance(dtype, pl.Decimal) ] if decimal_cols: @@ -404,8 +401,16 @@ async def vantage_dry_run_export( ) return frame.to_dicts() - usage_sample = _to_json_safe_dicts(data.head(min(50, len(data)))) if not data.is_empty() else [] - normalized_sample = _to_json_safe_dicts(normalized.head(min(50, len(normalized)))) if not normalized.is_empty() else [] + usage_sample = ( + _to_json_safe_dicts(data.head(min(50, len(data)))) + if not data.is_empty() + else [] + ) + normalized_sample = ( + _to_json_safe_dicts(normalized.head(min(50, len(normalized)))) + if not normalized.is_empty() + else [] + ) # Use the same pre-transform column names as # FocusExportEngine.dry_run_export_usage_data for consistency. @@ -437,14 +442,10 @@ async def vantage_dry_run_export( except HTTPException: raise except Exception as e: - verbose_proxy_logger.error( - f"Error performing Vantage dry run export: {str(e)}" - ) + verbose_proxy_logger.error(f"Error performing Vantage dry run export: {str(e)}") raise HTTPException( status_code=500, - detail={ - "error": f"Failed to perform Vantage dry run export: {str(e)}" - }, + detail={"error": f"Failed to perform Vantage dry run export: {str(e)}"}, ) diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 76662be175..df527d08af 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -454,8 +454,6 @@ class ProxyLogging: for hook in PROXY_HOOKS: proxy_hook = get_proxy_hook(hook) - import inspect - expected_args = inspect.getfullargspec(proxy_hook).args passed_in_args: Dict[str, Any] = {} if "internal_usage_cache" in expected_args: @@ -559,6 +557,10 @@ class ProxyLogging: "user_api_key_request_route": kwargs.get("user_api_key_request_route"), "mcp_tool_name": request_obj.tool_name, # Keep original for reference "mcp_arguments": request_obj.arguments, # Keep original for reference + # Raw Bearer token from the original HTTP request — allows guardrails + # (e.g. MCPJWTSigner) to independently verify the caller's identity + # before re-signing an outbound token (FR-5 verify+re-sign). + "incoming_bearer_token": kwargs.get("incoming_bearer_token"), } return synthetic_data @@ -824,17 +826,30 @@ class ProxyLogging: ) -> dict: """ Helper function to convert pre_call_hook response back to kwargs for MCP usage. + + Supports: + - modified_arguments: Override tool call arguments + - extra_headers: Inject custom headers into the outbound MCP request """ if not response_data: return original_kwargs - # Apply any argument modifications from the hook response modified_kwargs = original_kwargs.copy() - # If the response contains modified arguments, apply them if response_data.get("modified_arguments"): modified_kwargs["arguments"] = response_data["modified_arguments"] + if response_data.get("extra_headers"): + # Merge rather than replace — a prior guardrail in the chain may have + # already injected headers (e.g. tracing IDs). Later guardrails win on + # key collisions so that the most-specific guardrail (e.g. JWT signer) + # takes precedence over earlier ones. + existing = modified_kwargs.get("extra_headers") or {} + modified_kwargs["extra_headers"] = { + **existing, + **response_data["extra_headers"], + } + return modified_kwargs async def process_pre_call_hook_response(self, response, data, call_type): diff --git a/litellm/proxy/video_endpoints/utils.py b/litellm/proxy/video_endpoints/utils.py index 36203bdc77..689fe4a371 100644 --- a/litellm/proxy/video_endpoints/utils.py +++ b/litellm/proxy/video_endpoints/utils.py @@ -7,7 +7,9 @@ from litellm.types.videos.utils import encode_character_id_with_provider def extract_model_from_target_model_names(target_model_names: Any) -> Optional[str]: if isinstance(target_model_names, str): - target_model_names = [m.strip() for m in target_model_names.split(",") if m.strip()] + target_model_names = [ + m.strip() for m in target_model_names.split(",") if m.strip() + ] elif not isinstance(target_model_names, list): return None return target_model_names[0] if target_model_names else None diff --git a/litellm/responses/main.py b/litellm/responses/main.py index cd9ce67c26..2a320517a4 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -692,11 +692,11 @@ def responses( return run_async_function(aresponses_api_with_mcp, **mcp_call_kwargs) # get provider config - responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( - ProviderConfigManager.get_provider_responses_api_config( - model=model, - provider=custom_llm_provider, - ) + responses_api_provider_config: Optional[ + BaseResponsesAPIConfig + ] = ProviderConfigManager.get_provider_responses_api_config( + model=model, + provider=custom_llm_provider, ) local_vars.update(kwargs) @@ -908,11 +908,11 @@ def delete_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( - ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, - ) + responses_api_provider_config: Optional[ + BaseResponsesAPIConfig + ] = ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, ) if responses_api_provider_config is None: @@ -1089,11 +1089,11 @@ def get_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( - ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, - ) + responses_api_provider_config: Optional[ + BaseResponsesAPIConfig + ] = ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, ) if responses_api_provider_config is None: @@ -1247,11 +1247,11 @@ def list_input_items( if custom_llm_provider is None: raise ValueError("custom_llm_provider is required but passed as None") - responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( - ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, - ) + responses_api_provider_config: Optional[ + BaseResponsesAPIConfig + ] = ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, ) if responses_api_provider_config is None: @@ -1406,11 +1406,11 @@ def cancel_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( - ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, - ) + responses_api_provider_config: Optional[ + BaseResponsesAPIConfig + ] = ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, ) if responses_api_provider_config is None: @@ -1594,11 +1594,11 @@ def compact_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( - ProviderConfigManager.get_provider_responses_api_config( - model=model, - provider=custom_llm_provider, - ) + responses_api_provider_config: Optional[ + BaseResponsesAPIConfig + ] = ProviderConfigManager.get_provider_responses_api_config( + model=model, + provider=custom_llm_provider, ) if responses_api_provider_config is None: diff --git a/litellm/router.py b/litellm/router.py index f34368172a..46998abb16 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8611,6 +8611,7 @@ class Router: _model_info = deployment.get("model_info", {}) # see if we have the info for this model + _deployment_model = None # per-deployment model name (avoids overwriting the outer `model` group name) try: base_model = _model_info.get("base_model", None) if base_model is None: @@ -8618,7 +8619,7 @@ class Router: model_info = self.get_router_model_info( deployment=deployment, received_model_name=model ) - model = base_model or _litellm_params.get("model", None) + _deployment_model = base_model or _litellm_params.get("model", None) if ( isinstance(model_info, dict) @@ -8632,7 +8633,9 @@ class Router: _context_window_error = True _potential_error_str += ( "Model={}, Max Input Tokens={}, Got={}".format( - model, model_info["max_input_tokens"], input_tokens + _deployment_model, + model_info["max_input_tokens"], + input_tokens, ) ) continue @@ -8688,13 +8691,21 @@ class Router: ## INVALID PARAMS ## -> catch 'gpt-3.5-turbo-16k' not supporting 'response_format' param if request_kwargs is not None and litellm.drop_params is False: - # get supported params - model, custom_llm_provider, _, _ = litellm.get_llm_provider( - model=model, litellm_params=LiteLLM_Params(**_litellm_params) + # get supported params — use per-deployment model to avoid overwriting the outer model group name + _dep_model_for_params = _deployment_model or model + ( + _dep_model_for_params, + custom_llm_provider, + _, + _, + ) = litellm.get_llm_provider( + model=_dep_model_for_params, + litellm_params=LiteLLM_Params(**_litellm_params), ) supported_openai_params = litellm.get_supported_openai_params( - model=model, custom_llm_provider=custom_llm_provider + model=_dep_model_for_params, + custom_llm_provider=custom_llm_provider, ) if supported_openai_params is None: diff --git a/litellm/setup_wizard.py b/litellm/setup_wizard.py new file mode 100644 index 0000000000..ee5918e127 --- /dev/null +++ b/litellm/setup_wizard.py @@ -0,0 +1,668 @@ +# ruff: noqa: T201 +# flake8: noqa: T201 +""" +LiteLLM Interactive Setup Wizard + +Guides users through selecting LLM providers, entering API keys, +and generating a proxy config file — mirroring the Claude Code onboarding UX. +""" + +import importlib.metadata +import os +import re +import secrets +import sys +import sysconfig +from pathlib import Path +from typing import Dict, List, Optional, Set + +# termios / tty are Unix-only; fall back gracefully on Windows +try: + import termios + import tty + + _HAS_RAW_TERMINAL: bool = True +except ImportError: + termios = None # type: ignore[assignment] + tty = None # type: ignore[assignment] + _HAS_RAW_TERMINAL = False + +from litellm.utils import check_valid_key + +# --------------------------------------------------------------------------- +# Provider definitions +# --------------------------------------------------------------------------- +# Each entry describes one provider card shown in the wizard. +# `env_key` — primary env var name (None = no key needed, e.g. Ollama) +# `test_model` — model passed to check_valid_key for credential validation +# (None = skip validation, e.g. Azure needs a deployment name) +# `models` — default models written into the generated config +# --------------------------------------------------------------------------- + +PROVIDERS: List[Dict] = [ + { + "id": "openai", + "name": "OpenAI", + "description": "GPT-4o, GPT-4o-mini, o3-mini", + "env_key": "OPENAI_API_KEY", + "key_hint": "sk-...", + "test_model": "gpt-4o-mini", + "models": ["gpt-4o", "gpt-4o-mini"], + }, + { + "id": "anthropic", + "name": "Anthropic", + "description": "Claude Opus 4.6, Sonnet 4.6, Haiku 4.5", + "env_key": "ANTHROPIC_API_KEY", + "key_hint": "sk-ant-...", + "test_model": "claude-haiku-4-5-20251001", + "models": ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"], + }, + { + "id": "gemini", + "name": "Google Gemini", + "description": "Gemini 2.0 Flash, Gemini 2.5 Pro", + "env_key": "GEMINI_API_KEY", + "key_hint": "AIza...", + "test_model": "gemini/gemini-2.0-flash", + "models": ["gemini/gemini-2.0-flash", "gemini/gemini-2.5-pro"], + }, + { + "id": "azure", + "name": "Azure OpenAI", + "description": "GPT-4o via Azure", + "env_key": "AZURE_API_KEY", + "key_hint": "your-azure-key", + "test_model": None, # needs deployment name — skip validation + "models": [], + "needs_api_base": True, + "api_base_hint": "https://.openai.azure.com/", + "api_version": "2024-07-01-preview", + }, + { + "id": "bedrock", + "name": "AWS Bedrock", + "description": "Claude 3.5, Llama 3 via AWS", + "env_key": "AWS_ACCESS_KEY_ID", + "key_hint": "AKIA...", + "test_model": None, # multi-key auth — skip validation + "models": ["bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"], + "extra_keys": ["AWS_SECRET_ACCESS_KEY", "AWS_REGION_NAME"], + "extra_hints": ["your-secret-key", "us-east-1"], + }, + { + "id": "ollama", + "name": "Ollama", + "description": "Local models (llama3.2, mistral, etc.)", + "env_key": None, + "key_hint": None, + "test_model": None, # local — no remote validation + "models": ["ollama/llama3.2", "ollama/mistral"], + "api_base": "http://localhost:11434", + }, +] + + +# --------------------------------------------------------------------------- +# ANSI colour helpers +# --------------------------------------------------------------------------- + +_ANSI_RE = re.compile(r"\033\[[^m]*m") + +_ORANGE = "\033[38;2;215;119;87m" +_DIM = "\033[2m" +_BOLD = "\033[1m" +_GREEN = "\033[38;2;78;186;101m" +_BLUE = "\033[38;2;177;185;249m" +_GREY = "\033[38;2;153;153;153m" +_RESET = "\033[0m" +_CHECK = "✔" +_CROSS = "✘" + +_CURSOR_HIDE = "\033[?25l" +_CURSOR_SHOW = "\033[?25h" +_MOVE_UP = "\033[{}A" + + +def _supports_color() -> bool: + return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None + + +def _c(code: str, text: str) -> str: + return f"{code}{text}{_RESET}" if _supports_color() else text + + +def orange(t: str) -> str: + return _c(_ORANGE, t) + + +def bold(t: str) -> str: + return _c(_BOLD, t) + + +def green(t: str) -> str: + return _c(_GREEN, t) + + +def blue(t: str) -> str: + return _c(_BLUE, t) + + +def grey(t: str) -> str: + return _c(_GREY, t) + + +def dim(t: str) -> str: + return _c(_DIM, t) + + +def _divider() -> str: + """Return a styled divider line (evaluated at call-time, not import-time).""" + return dim(" " + "╌" * 74) + + +def _styled_input(prompt: str) -> str: + """ + Like input() but wraps ANSI sequences in readline ignore markers + (\\001...\\002) so readline correctly tracks the cursor column. + In non-TTY contexts, strips ANSI entirely so no escape codes appear. + """ + if sys.stdout.isatty(): + rl_prompt = _ANSI_RE.sub(lambda m: f"\001{m.group()}\002", prompt) + else: + rl_prompt = _ANSI_RE.sub("", prompt) + return input(rl_prompt).strip() + + +def _yaml_escape(value: str) -> str: + """Escape a string for safe embedding in a double-quoted YAML scalar.""" + return ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + +# --------------------------------------------------------------------------- +# Layout constants +# --------------------------------------------------------------------------- + +LITELLM_ASCII = r""" + ██╗ ██╗████████╗███████╗██╗ ██╗ ███╗ ███╗ + ██║ ██║╚══██╔══╝██╔════╝██║ ██║ ████╗ ████║ + ██║ ██║ ██║ █████╗ ██║ ██║ ██╔████╔██║ + ██║ ██║ ██║ ██╔══╝ ██║ ██║ ██║╚██╔╝██║ + ███████╗██║ ██║ ███████╗███████╗███████╗██║ ╚═╝ ██║ + ╚══════╝╚═╝ ╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝ +""" + + +# --------------------------------------------------------------------------- +# Setup wizard +# --------------------------------------------------------------------------- + + +class SetupWizard: + """ + Interactive onboarding wizard: provider selection → API keys → config file. + + All methods are static — the class is purely a namespace with clear + single-responsibility sections. Entry point: SetupWizard.run(). + """ + + # ── entry point ───────────────────────────────────────────────────────── + + @staticmethod + def run() -> None: + try: + SetupWizard._wizard() + except (KeyboardInterrupt, EOFError): + print(f"\n\n {grey('Setup cancelled.')}\n") + + # ── wizard steps ──────────────────────────────────────────────────────── + + @staticmethod + def _wizard() -> None: + SetupWizard._print_welcome() + print(f" {bold('Lets get started.')}") + print() + + providers = SetupWizard._select_providers() + env_vars = SetupWizard._collect_keys(providers) + port, master_key = SetupWizard._proxy_settings() + + config_path = Path(os.getcwd()) / "litellm_config.yaml" + try: + config_path.write_text( + SetupWizard._build_config(providers, env_vars, master_key) + ) + except OSError as exc: + print(f"\n {bold(_CROSS + ' Could not write config:')} {exc}") + print(" Try running from a directory you have write access to.\n") + return + + SetupWizard._print_success(config_path, port, master_key) + SetupWizard._offer_start(config_path, port, master_key) + + # ── welcome ───────────────────────────────────────────────────────────── + + @staticmethod + def _print_welcome() -> None: + try: + version = importlib.metadata.version("litellm") + except Exception: + version = "unknown" + print() + print(orange(LITELLM_ASCII.rstrip("\n"))) + print(f" {orange('Welcome')} to {bold('LiteLLM')} {grey('v' + version)}") + print() + print(_divider()) + print() + + # ── provider selector ─────────────────────────────────────────────────── + + @staticmethod + def _select_providers() -> List[Dict]: + """Arrow-key multi-select. Falls back to number input if /dev/tty unavailable.""" + if not _HAS_RAW_TERMINAL: + return SetupWizard._select_fallback() + try: + return SetupWizard._select_interactive() + except OSError: + return SetupWizard._select_fallback() + + @staticmethod + def _read_key() -> str: + """Read one keypress from /dev/tty in raw mode.""" + assert ( + termios is not None and tty is not None + ) # only called when _HAS_RAW_TERMINAL + with open("/dev/tty", "rb") as tty_fh: + fd = tty_fh.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = tty_fh.read(1) + if ch == b"\x1b": + ch2 = tty_fh.read(1) + if ch2 == b"[": + ch3 = tty_fh.read(1) + return "\x1b[" + ch3.decode("utf-8", errors="replace") + return "\x1b" + ch2.decode("utf-8", errors="replace") + return ch.decode("utf-8", errors="replace") + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + @staticmethod + def _render_selector(cursor: int, selected: Set[int], first_render: bool) -> int: + """Draw or redraw the provider list. Returns the number of lines printed.""" + lines = [ + f"\n {bold('Add your first model')}\n", + grey(" ↑↓ to navigate · Space to select · Enter to confirm") + "\n", + "\n", + ] + for i, p in enumerate(PROVIDERS): + arrow = blue("❯") if i == cursor else " " + bullet = green("◉") if i in selected else grey("○") + name_str = bold(p["name"]) if i == cursor else p["name"] + lines.append(f" {arrow} {bullet} {name_str} {grey(p['description'])}\n") + lines.append("\n") + + content = "".join(lines) + if not first_render and _supports_color(): + sys.stdout.write(_MOVE_UP.format(content.count("\n"))) + sys.stdout.write(content) + sys.stdout.flush() + return content.count("\n") + + @staticmethod + def _select_interactive() -> List[Dict]: + cursor = 0 + selected: set[int] = set() + + if _supports_color(): + sys.stdout.write(_CURSOR_HIDE) + sys.stdout.flush() + try: + SetupWizard._render_selector(cursor, selected, first_render=True) + while True: + key = SetupWizard._read_key() + dirty = False + if key == "\x1b[A": + cursor = (cursor - 1) % len(PROVIDERS) + dirty = True + elif key == "\x1b[B": + cursor = (cursor + 1) % len(PROVIDERS) + dirty = True + elif key == " ": + selected.symmetric_difference_update({cursor}) + dirty = True + elif key in ("\r", "\n"): + if not selected: + selected.add(cursor) + break + elif key in ("\x03", "\x04"): + raise KeyboardInterrupt + if dirty: + SetupWizard._render_selector(cursor, selected, first_render=False) + finally: + if _supports_color(): + sys.stdout.write(_CURSOR_SHOW) + sys.stdout.flush() + + return [PROVIDERS[i] for i in sorted(selected)] + + @staticmethod + def _select_fallback() -> List[Dict]: + """Number-based fallback when raw terminal input is unavailable.""" + print() + print(f" {bold('Add your first model')}") + print( + grey( + " Enter numbers separated by commas (e.g. 1,2). Press Enter to confirm." + ) + ) + print() + for i, p in enumerate(PROVIDERS, 1): + print(f" {grey(str(i) + '.')} {bold(p['name'])} {grey(p['description'])}") + print() + + while True: + raw = _styled_input(f" {blue('❯')} Provider(s): ") + if not raw: + print(grey(" Please select at least one provider.")) + continue + try: + nums = [ + int(x.strip()) + for x in raw.replace(" ", ",").split(",") + if x.strip() + ] + valid = sorted({n for n in nums if 1 <= n <= len(PROVIDERS)}) + if not valid: + print(grey(f" Enter numbers between 1 and {len(PROVIDERS)}.")) + continue + return [PROVIDERS[i - 1] for i in valid] + except ValueError: + print(grey(" Enter numbers separated by commas, e.g. 1,3")) + + # ── key collection ─────────────────────────────────────────────────────── + + @staticmethod + def _collect_keys(providers: List[Dict]) -> Dict[str, str]: + env_vars: Dict[str, str] = {} + print() + print(_divider()) + print() + print(f" {bold('Enter your API keys')}") + print(grey(" Keys are stored only in the generated config file.")) + print( + grey( + " Tip: add litellm_config.yaml to .gitignore to avoid committing secrets." + ) + ) + print() + + for p in providers: + if p["env_key"] is None: + print( + f" {green(p['name'])}: {grey('no key needed (uses local Ollama)')}" + ) + continue + + key = SetupWizard._prompt_key(p) + if not key: + continue + + for extra_key, extra_hint in zip( + p.get("extra_keys", []), p.get("extra_hints", []) + ): + val = _styled_input(f" {blue('❯')} {extra_key} {grey(extra_hint)}: ") + if val: + env_vars[extra_key] = val + + if p.get("needs_api_base"): + api_base = _styled_input( + f" {blue('❯')} Azure endpoint URL {grey(p.get('api_base_hint', ''))}: " + ) + if api_base: + env_vars[f"_LITELLM_AZURE_API_BASE_{p['id'].upper()}"] = api_base + deployment = _styled_input( + f" {blue('❯')} Azure deployment name {grey('(e.g. my-gpt4o)')}: " + ) + if deployment: + env_vars[ + f"_LITELLM_AZURE_DEPLOYMENT_{p['id'].upper()}" + ] = deployment + + # Store the key returned by validation — may be a re-entered replacement + env_vars[p["env_key"]] = SetupWizard._validate_and_report(p, key) + + return env_vars + + @staticmethod + def _prompt_key(provider: Dict) -> str: + """Prompt for a provider's API key, with skip option. Returns the key or ''.""" + hint = grey(provider.get("key_hint", "")) + while True: + key = _styled_input( + f" {blue('❯')} {bold(provider['name'])} API key {hint}: " + ) + if key: + return key + print(grey(" Key is required. Leave blank to skip this provider.")) + if _styled_input(grey(" Skip? (y/N): ")).lower() == "y": + return "" + + @staticmethod + def _validate_and_report(provider: Dict, api_key: str) -> str: + """ + Validate credentials using litellm.utils.check_valid_key and print result. + Offers a re-entry loop on failure. Returns the final (possibly re-entered) key. + """ + test_model: Optional[str] = provider.get("test_model") + if not test_model: + return api_key # Azure / Bedrock / Ollama — skip validation + + while True: + print( + f" {grey('Testing connection to ' + provider['name'] + '...')}", + flush=True, + ) + valid = check_valid_key(model=test_model, api_key=api_key) + if valid: + print( + f" {green(_CHECK)} {bold(provider['name'])} connected successfully" + ) + return api_key + + print(f" {_CROSS} {bold(provider['name'])} {grey('— invalid API key')}") + if ( + _styled_input(f" {blue('❯')} Re-enter key? {grey('(y/N)')}: ").lower() + != "y" + ): + return api_key + + hint = grey(provider.get("key_hint", "")) + new_key = _styled_input( + f" {blue('❯')} {bold(provider['name'])} API key {hint}: " + ) + if not new_key: + return api_key + api_key = new_key + + # ── proxy settings ─────────────────────────────────────────────────────── + + @staticmethod + def _proxy_settings() -> "tuple[int, str]": + print() + print(_divider()) + print() + print(f" {bold('Proxy settings')}") + print() + port = 4000 + while True: + port_raw = _styled_input(f" {blue('❯')} Port {grey('[4000]')}: ") + if not port_raw: + break + if port_raw.isdigit() and 1 <= int(port_raw) <= 65535: + port = int(port_raw) + break + print(grey(" Enter a valid port number (1–65535).")) + key_raw = _styled_input(f" {blue('❯')} Master key {grey('[auto-generate]')}: ") + master_key = key_raw if key_raw else f"sk-{secrets.token_urlsafe(32)}" + return port, master_key + + # ── config generation ──────────────────────────────────────────────────── + + @staticmethod + def _build_config( + providers: List[Dict], + env_vars: Dict[str, str], + master_key: str, + ) -> str: + env_copy = dict(env_vars) # work on a copy — do not mutate caller's dict + lines = ["model_list:"] + for p in providers: + # Only emit models for providers that actually have credentials + has_creds = p["env_key"] is None or p["env_key"] in env_copy + if not has_creds: + continue + + if p["id"] == "azure": + deployment = env_copy.pop( + f"_LITELLM_AZURE_DEPLOYMENT_{p['id'].upper()}", "" + ) + if not deployment: + continue # skip Azure entirely if no deployment name was provided + models = [f"azure/{deployment}"] + else: + models = p["models"] + + for model in models: + raw_display = model.split("/")[-1] if "/" in model else model + # Qualify azure display names to avoid collision with OpenAI model names + display = f"azure-{raw_display}" if p["id"] == "azure" else raw_display + lines += [ + f" - model_name: {display}", + " litellm_params:", + f" model: {model}", + ] + if p["env_key"] and p["env_key"] in env_copy: + lines.append(f" api_key: os.environ/{p['env_key']}") + if p.get("api_base"): + lines.append( + f' api_base: "{_yaml_escape(str(p["api_base"]))}"' + ) + elif p.get("needs_api_base"): + azure_base_key = f"_LITELLM_AZURE_API_BASE_{p['id'].upper()}" + if azure_base_key in env_copy: + lines.append( + f' api_base: "{_yaml_escape(env_copy.pop(azure_base_key))}"' + ) + if p.get("api_version"): + lines.append(f" api_version: {p['api_version']}") + + lines += [ + "", + "general_settings:", + f' master_key: "{_yaml_escape(master_key)}"', + "", + ] + + real_vars = {k: v for k, v in env_copy.items() if not k.startswith("_LITELLM_")} + if real_vars: + lines.append("environment_variables:") + for k, v in real_vars.items(): + lines.append(f' {k}: "{_yaml_escape(v)}"') + lines.append("") + + return "\n".join(lines) + + # ── success + launch ───────────────────────────────────────────────────── + + @staticmethod + def _print_success(config_path: Path, port: int, master_key: str) -> None: + print() + print(_divider()) + print() + print(f" {green(_CHECK + ' Config saved')} → {bold(str(config_path))}") + print() + print(f" {bold('To start your proxy:')}") + print() + print(f" {grey('$')} litellm --config {config_path} --port {port}") + print() + print(f" {bold('Then set your client:')}") + print() + print(f" export OPENAI_BASE_URL=http://localhost:{port}") + print(f" export OPENAI_API_KEY={master_key}") + print() + print(_divider()) + print() + + @staticmethod + def _offer_start(config_path: Path, port: int, master_key: str) -> None: + start = _styled_input( + f" {blue('❯')} Start the proxy now? {grey('(Y/n)')}: " + ).lower() + if start not in ("", "y", "yes"): + print() + print( + f" Run {bold(f'litellm --config {config_path}')} whenever you're ready." + ) + print() + print( + grey(f" Quick test once running: curl http://localhost:{port}/health") + ) + print() + return + + print() + print(_divider()) + print() + print(f" {bold('Proxy is starting on')} http://localhost:{port}") + print() + print(grey(" Your proxy is OpenAI-compatible. Point any OpenAI SDK at it:")) + print() + print(f" export OPENAI_BASE_URL=http://localhost:{port}") + print(f" export OPENAI_API_KEY={master_key}") + print() + print(grey(" Quick test (in another terminal):")) + print() + print(f" curl http://localhost:{port}/health") + print() + print(grey(" Dashboard:")) + print() + print(f" http://localhost:{port}/ui {grey('(login with your master key)')}") + print() + print(_divider()) + print() + print(f" {green(_CHECK)} Starting… {grey('(Ctrl+C to stop)')}") + print() + + scripts_dir = sysconfig.get_path("scripts") + litellm_bin = os.path.join(scripts_dir or "", "litellm") + try: + os.execlp( + litellm_bin, + litellm_bin, + "--config", + str(config_path), + "--port", + str(port), + ) # noqa: S606 + except OSError as exc: + print(f"\n {bold(_CROSS + ' Could not start proxy:')} {exc}") + print(f" Run manually: litellm --config {config_path} --port {port}\n") + + +# --------------------------------------------------------------------------- +# Public entrypoint +# --------------------------------------------------------------------------- + + +def run_setup_wizard() -> None: + """Run the interactive setup wizard. Called by `litellm --setup`.""" + SetupWizard.run() diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 27fa27e6da..f798f05380 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -79,6 +79,7 @@ class SupportedGuardrailIntegrations(Enum): SEMANTIC_GUARD = "semantic_guard" MCP_END_USER_PERMISSION = "mcp_end_user_permission" BLOCK_CODE_EXECUTION = "block_code_execution" + MCP_JWT_SIGNER = "mcp_jwt_signer" class Role(Enum): diff --git a/litellm/types/proxy/vantage_endpoints.py b/litellm/types/proxy/vantage_endpoints.py index cf4f0a6685..84199e7834 100644 --- a/litellm/types/proxy/vantage_endpoints.py +++ b/litellm/types/proxy/vantage_endpoints.py @@ -39,7 +39,8 @@ class VantageExportRequest(BaseModel): """Request model for Vantage export operations (actual export, no default limit)""" limit: Optional[int] = Field( - None, description="Optional limit on number of records to export (default: no limit)" + None, + description="Optional limit on number of records to export (default: no limit)", ) start_time_utc: Optional[datetime] = Field( None, description="Start time for data export in UTC" diff --git a/litellm/types/videos/utils.py b/litellm/types/videos/utils.py index 3a100129bc..bf51fdda37 100644 --- a/litellm/types/videos/utils.py +++ b/litellm/types/videos/utils.py @@ -195,7 +195,9 @@ def decode_character_id_with_provider(encoded_character_id: str) -> DecodedChara character_id=decoded_character_id, ) except Exception as e: - verbose_logger.debug(f"Error decoding character_id '{encoded_character_id}': {e}") + verbose_logger.debug( + f"Error decoding character_id '{encoded_character_id}': {e}" + ) return DecodedCharacterId( custom_llm_provider=None, model_id=None, diff --git a/litellm/videos/main.py b/litellm/videos/main.py index d32a873e0b..cd61293cd1 100644 --- a/litellm/videos/main.py +++ b/litellm/videos/main.py @@ -1186,13 +1186,17 @@ def video_create_character( litellm_params = GenericLiteLLMParams(**kwargs) - provider_config: Optional[BaseVideoConfig] = ProviderConfigManager.get_provider_video_config( + provider_config: Optional[ + BaseVideoConfig + ] = ProviderConfigManager.get_provider_video_config( model=None, provider=litellm.LlmProviders(custom_llm_provider), ) if provider_config is None: - raise ValueError(f"video create character is not supported for {custom_llm_provider}") + raise ValueError( + f"video create character is not supported for {custom_llm_provider}" + ) local_vars.update(kwargs) request_params: Dict = {"name": name} @@ -1311,13 +1315,17 @@ def video_get_character( litellm_params = GenericLiteLLMParams(**kwargs) - provider_config: Optional[BaseVideoConfig] = ProviderConfigManager.get_provider_video_config( + provider_config: Optional[ + BaseVideoConfig + ] = ProviderConfigManager.get_provider_video_config( model=None, provider=litellm.LlmProviders(custom_llm_provider), ) if provider_config is None: - raise ValueError(f"video get character is not supported for {custom_llm_provider}") + raise ValueError( + f"video get character is not supported for {custom_llm_provider}" + ) local_vars.update(kwargs) request_params: Dict = {"character_id": character_id} @@ -1439,7 +1447,9 @@ def video_edit( litellm_params = GenericLiteLLMParams(**kwargs) - provider_config: Optional[BaseVideoConfig] = ProviderConfigManager.get_provider_video_config( + provider_config: Optional[ + BaseVideoConfig + ] = ProviderConfigManager.get_provider_video_config( model=None, provider=litellm.LlmProviders(custom_llm_provider), ) @@ -1572,16 +1582,24 @@ def video_extension( litellm_params = GenericLiteLLMParams(**kwargs) - provider_config: Optional[BaseVideoConfig] = ProviderConfigManager.get_provider_video_config( + provider_config: Optional[ + BaseVideoConfig + ] = ProviderConfigManager.get_provider_video_config( model=None, provider=litellm.LlmProviders(custom_llm_provider), ) if provider_config is None: - raise ValueError(f"video extension is not supported for {custom_llm_provider}") + raise ValueError( + f"video extension is not supported for {custom_llm_provider}" + ) local_vars.update(kwargs) - request_params: Dict = {"video_id": video_id, "prompt": prompt, "seconds": seconds} + request_params: Dict = { + "video_id": video_id, + "prompt": prompt, + "seconds": seconds, + } litellm_logging_obj.update_environment_variables( model="", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 6786fc3359..181045809f 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -32354,6 +32354,53 @@ "supports_vision": true, "supports_web_search": true }, + "xai/grok-4.20-multi-agent-beta-0309": { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 2000000, + "max_output_tokens": 2000000, + "max_tokens": 2000000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-4.20-beta-0309-reasoning": { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 2000000, + "max_output_tokens": 2000000, + "max_tokens": 2000000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-4.20-beta-0309-non-reasoning": { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "xai", + "max_input_tokens": 2000000, + "max_output_tokens": 2000000, + "max_tokens": 2000000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "xai/grok-beta": { "input_cost_per_token": 5e-06, "litellm_provider": "xai", diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000000..b9912287b7 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# LiteLLM Installer +# Usage: curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/install.sh | sh +# +# NOTE: set -e without pipefail for POSIX sh compatibility (dash on Ubuntu/Debian +# ignores the shebang when invoked as `sh` and does not support `pipefail`). +set -eu + +MIN_PYTHON_MAJOR=3 +MIN_PYTHON_MINOR=9 + +# NOTE: before merging, this must stay as "litellm[proxy]" to install from PyPI. +LITELLM_PACKAGE="litellm[proxy]" + +# ── colours ──────────────────────────────────────────────────────────────── +if [ -t 1 ]; then + BOLD='\033[1m' + GREEN='\033[38;2;78;186;101m' + GREY='\033[38;2;153;153;153m' + RESET='\033[0m' +else + BOLD='' GREEN='' GREY='' RESET='' +fi + +info() { printf "${GREY} %s${RESET}\n" "$*"; } +success() { printf "${GREEN} ✔ %s${RESET}\n" "$*"; } +header() { printf "${BOLD} %s${RESET}\n" "$*"; } +die() { printf "\n Error: %s\n\n" "$*" >&2; exit 1; } + +# ── banner ───────────────────────────────────────────────────────────────── +echo "" +cat << 'EOF' + ██╗ ██╗████████╗███████╗██╗ ██╗ ███╗ ███╗ + ██║ ██║╚══██╔══╝██╔════╝██║ ██║ ████╗ ████║ + ██║ ██║ ██║ █████╗ ██║ ██║ ██╔████╔██║ + ██║ ██║ ██║ ██╔══╝ ██║ ██║ ██║╚██╔╝██║ + ███████╗██║ ██║ ███████╗███████╗███████╗██║ ╚═╝ ██║ + ╚══════╝╚═╝ ╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝ +EOF +printf " ${BOLD}LiteLLM Installer${RESET} ${GREY}— unified gateway for 100+ LLM providers${RESET}\n\n" + +# ── OS detection ─────────────────────────────────────────────────────────── +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Darwin) PLATFORM="macOS ($ARCH)" ;; + Linux) PLATFORM="Linux ($ARCH)" ;; + *) die "Unsupported OS: $OS. LiteLLM supports macOS and Linux." ;; +esac + +info "Platform: $PLATFORM" + +# ── Python detection ─────────────────────────────────────────────────────── +PYTHON_BIN="" +for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1; then + major="$("$candidate" -c 'import sys; print(sys.version_info.major)' 2>/dev/null || true)" + minor="$("$candidate" -c 'import sys; print(sys.version_info.minor)' 2>/dev/null || true)" + if [ "${major:-0}" -ge "$MIN_PYTHON_MAJOR" ] && [ "${minor:-0}" -ge "$MIN_PYTHON_MINOR" ]; then + PYTHON_BIN="$(command -v "$candidate")" + info "Python: $("$candidate" --version 2>&1)" + break + fi + fi +done + +if [ -z "$PYTHON_BIN" ]; then + die "Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ is required but not found. + Install it from https://python.org/downloads or via your package manager: + macOS: brew install python@3 + Ubuntu: sudo apt install python3 python3-pip" +fi + +# ── pip detection ────────────────────────────────────────────────────────── +if ! "$PYTHON_BIN" -m pip --version >/dev/null 2>&1; then + die "pip is not available. Install it with: + $PYTHON_BIN -m ensurepip --upgrade" +fi + +# ── install ──────────────────────────────────────────────────────────────── +echo "" +header "Installing litellm[proxy]…" +echo "" + +"$PYTHON_BIN" -m pip install --upgrade "${LITELLM_PACKAGE}" \ + || die "pip install failed. Try manually: $PYTHON_BIN -m pip install '${LITELLM_PACKAGE}'" + +# ── find the litellm binary installed by pip for this Python ─────────────── +# sysconfig.get_path('scripts') is where pip puts console scripts — reliable +# even when the Python lives in a libexec/ symlink tree (e.g. Homebrew). +SCRIPTS_DIR="$("$PYTHON_BIN" -c 'import sysconfig; print(sysconfig.get_path("scripts"))')" +LITELLM_BIN="${SCRIPTS_DIR}/litellm" + +if [ ! -x "$LITELLM_BIN" ]; then + # Fall back to user-base bin (pip install --user) + USER_BIN="$("$PYTHON_BIN" -c 'import site; print(site.getuserbase())')/bin" + LITELLM_BIN="${USER_BIN}/litellm" +fi + +if [ ! -x "$LITELLM_BIN" ]; then + die "litellm binary not found after install. Try: $PYTHON_BIN -m pip install --user '${LITELLM_PACKAGE}'" +fi + +# ── success banner ───────────────────────────────────────────────────────── +echo "" +success "LiteLLM installed" + +installed_ver="$("$LITELLM_BIN" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" +[ -n "$installed_ver" ] && info "Version: $installed_ver" + +# ── PATH hint ────────────────────────────────────────────────────────────── +if ! command -v litellm >/dev/null 2>&1; then + info "Note: add litellm to your PATH: export PATH=\"\$PATH:${SCRIPTS_DIR}\"" +fi + +# ── launch setup wizard ──────────────────────────────────────────────────── +echo "" +printf " ${BOLD}Run the interactive setup wizard?${RESET} ${GREY}(Y/n)${RESET}: " +# /dev/tty may be unavailable in Docker/CI — default to yes if it can't be read +answer="" +if [ -r /dev/tty ]; then + read -r answer = 2, ( + f"Expected at least 2 batch items (trace-create + generation-create) " + f"after filtering by trace_id={trace_id}, " + f"but got {len(actual_request_body['batch'])}. " + f"Items: {json.dumps(actual_request_body['batch'], indent=2)}" + ) + # Replace dynamic values in actual request body for item in actual_request_body["batch"]: @@ -150,19 +173,36 @@ class TestLangfuseLogging: """Helper method to verify Langfuse API calls""" await asyncio.sleep(3) - # Verify the call + # Verify at least one call was made assert mock_post.call_count >= 1 - url = mock_post.call_args[0][0] - request_body = mock_post.call_args[1].get("content") - # Parse the JSON string into a dict for assertions - actual_request_body = json.loads(request_body) + # Aggregate batch items from ALL calls — the Langfuse SDK may split + # trace-create and generation-create across separate HTTP flushes. + langfuse_url = "https://us.cloud.langfuse.com/api/public/ingestion" + all_batch_items: list = [] + metadata: Optional[dict] = None + for call in mock_post.call_args_list: + url = call[0][0] + if url != langfuse_url: + continue + request_body = call[1].get("content") + if request_body: + body = json.loads(request_body) + all_batch_items.extend(body.get("batch", [])) + if metadata is None: + metadata = body.get("metadata") - print("\nMocked Request Details:") - print(f"URL: {url}") + assert len(all_batch_items) > 0, "No Langfuse ingestion calls found" + assert metadata is not None, "No metadata found in Langfuse calls" + + actual_request_body = { + "batch": all_batch_items, + "metadata": metadata, + } + + print("\nMocked Request Details (aggregated from all calls):") print(f"Request Body: {json.dumps(actual_request_body, indent=4)}") - assert url == "https://us.cloud.langfuse.com/api/public/ingestion" assert_langfuse_request_matches_expected( actual_request_body, expected_file_name, @@ -170,6 +210,7 @@ class TestLangfuseLogging: ) @pytest.mark.asyncio + @pytest.mark.flaky(retries=3, delay=1) async def test_langfuse_logging_completion(self, mock_setup): """Test Langfuse logging for chat completion""" setup = mock_setup @@ -185,6 +226,7 @@ class TestLangfuseLogging: ) @pytest.mark.asyncio + @pytest.mark.flaky(retries=3, delay=1) async def test_langfuse_logging_completion_with_tags(self, mock_setup): """Test Langfuse logging for chat completion with tags""" setup = mock_setup @@ -203,6 +245,7 @@ class TestLangfuseLogging: ) @pytest.mark.asyncio + @pytest.mark.flaky(retries=3, delay=1) async def test_langfuse_logging_completion_with_tags_stream(self, mock_setup): """Test Langfuse logging for chat completion with tags""" setup = mock_setup @@ -223,6 +266,7 @@ class TestLangfuseLogging: ) @pytest.mark.asyncio + @pytest.mark.flaky(retries=3, delay=1) async def test_langfuse_logging_completion_with_langfuse_metadata(self, mock_setup): """Test Langfuse logging for chat completion with metadata for langfuse""" setup = mock_setup @@ -252,6 +296,7 @@ class TestLangfuseLogging: ) @pytest.mark.asyncio + @pytest.mark.flaky(retries=3, delay=1) async def test_langfuse_logging_with_non_serializable_metadata(self, mock_setup): """Test Langfuse logging with metadata that requires preparation (Pydantic models, sets, etc)""" from pydantic import BaseModel @@ -358,6 +403,7 @@ class TestLangfuseLogging: ) @pytest.mark.asyncio + @pytest.mark.flaky(retries=3, delay=1) async def test_langfuse_logging_completion_with_malformed_llm_response( self, mock_setup ): @@ -387,6 +433,7 @@ class TestLangfuseLogging: ) @pytest.mark.asyncio + @pytest.mark.flaky(retries=3, delay=1) async def test_langfuse_logging_completion_with_bedrock_llm_response( self, mock_setup ): @@ -418,6 +465,7 @@ class TestLangfuseLogging: setup["mock_post"], "completion_with_bedrock_call.json", setup["trace_id"] ) @pytest.mark.asyncio + @pytest.mark.flaky(retries=3, delay=1) async def test_langfuse_logging_completion_with_vertex_llm_response( self, mock_setup ): @@ -449,6 +497,7 @@ class TestLangfuseLogging: ) @pytest.mark.asyncio + @pytest.mark.flaky(retries=3, delay=1) async def test_langfuse_logging_vllm_embedding(self, mock_setup): """ Test that the request sent to the vllm embedding endpoint is correct. @@ -500,6 +549,7 @@ class TestLangfuseLogging: ) @pytest.mark.asyncio + @pytest.mark.flaky(retries=3, delay=1) async def test_langfuse_logging_with_router(self, mock_setup): """Test Langfuse logging with router""" litellm._turn_on_debug() diff --git a/tests/spend_tracking_tests/test_spend_accuracy_tests.py b/tests/spend_tracking_tests/test_spend_accuracy_tests.py index 11b8e209dc..90efe28ab8 100644 --- a/tests/spend_tracking_tests/test_spend_accuracy_tests.py +++ b/tests/spend_tracking_tests/test_spend_accuracy_tests.py @@ -308,16 +308,19 @@ async def test_long_term_spend_accuracy_with_bursts(): response = await chat_completion(session, key) print(f"Burst 2 - Request {i + 1}/{BURST_2_REQUESTS} completed") - # Poll until key spend reflects burst 2 - burst_1_spend = intermediate_key_info["info"]["spend"] + # Poll until key spend reaches expected total (burst 1 + burst 2) start = time.time() while time.time() - start < 120: key_info_check = await get_spend_info(session, "key", key) current_spend = key_info_check["info"]["spend"] - if current_spend > burst_1_spend: - print(f"Key spend increased to {current_spend} after {time.time() - start:.1f}s") + if abs(current_spend - expected_spend) < TOLERANCE: + print( + f"Total spend reached expected {expected_spend} after {time.time() - start:.1f}s" + ) break - print(f"Key spend still {current_spend}, waiting for burst 2 flush...") + print( + f"Key spend {current_spend}, expected {expected_spend}, waiting..." + ) await asyncio.sleep(10) # Allow extra time for all entity spend aggregations diff --git a/tests/test_litellm/interactions/test_openapi_compliance.py b/tests/test_litellm/interactions/test_openapi_compliance.py index 5b2edcf1ee..a22244f3f8 100644 --- a/tests/test_litellm/interactions/test_openapi_compliance.py +++ b/tests/test_litellm/interactions/test_openapi_compliance.py @@ -77,6 +77,11 @@ class TestRequestCompliance: schema = spec_dict["components"]["schemas"]["CreateModelInteractionParams"] input_schema = schema["properties"]["input"] + # The input property may be inline oneOf or a $ref to InteractionsInput + if "$ref" in input_schema: + ref_name = input_schema["$ref"].split("/")[-1] + input_schema = spec_dict["components"]["schemas"][ref_name] + # Should be oneOf with multiple types assert "oneOf" in input_schema @@ -100,10 +105,21 @@ class TestRequestCompliance: assert "discriminator" in content_schema assert content_schema["discriminator"]["propertyName"] == "type" - # Check TextContent is an option - mapping = content_schema["discriminator"]["mapping"] - assert "text" in mapping - print(f"Content type discriminator mapping: {list(mapping.keys())}") + # Check TextContent is an option (via mapping if present, or via oneOf refs) + mapping = content_schema["discriminator"].get("mapping") + if mapping: + assert "text" in mapping + print(f"Content type discriminator mapping: {list(mapping.keys())}") + else: + # Discriminator without explicit mapping — verify via oneOf + one_of = content_schema.get("oneOf", []) + ref_names = [ + opt["$ref"].split("/")[-1] for opt in one_of if "$ref" in opt + ] + assert "TextContent" in ref_names, ( + f"TextContent not found in oneOf refs: {ref_names}" + ) + print(f"Content type discriminator (no mapping), oneOf refs: {ref_names}") def test_text_content_schema(self, spec_dict): """Verify TextContent schema.""" diff --git a/tests/test_litellm/llms/xai/test_xai_cost_calculator.py b/tests/test_litellm/llms/xai/test_xai_cost_calculator.py index 1c2de953c2..00955bc525 100644 --- a/tests/test_litellm/llms/xai/test_xai_cost_calculator.py +++ b/tests/test_litellm/llms/xai/test_xai_cost_calculator.py @@ -386,8 +386,56 @@ class TestXAICostCalculator: completion_tokens=50, total_tokens=150, ) - + web_search_cost = cost_per_web_search_request(usage=usage, model_info={}) - + # Expected cost: No web search data = $0.0 assert web_search_cost == 0.0 + + def test_grok_4_20_beta_reasoning_cost_calculation(self): + """Test cost calculation for grok-4.20-beta-0309-reasoning model.""" + usage = Usage(prompt_tokens=100, completion_tokens=200, total_tokens=300) + + prompt_cost, completion_cost = cost_per_token( + model="grok-4.20-beta-0309-reasoning", usage=usage + ) + + # Input: 100 tokens * $2e-6 = $0.0002 + # Output: 200 tokens * $6e-6 = $0.0012 + expected_prompt_cost = 100 * 2e-6 + expected_completion_cost = 200 * 6e-6 + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-10) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-10) + + def test_grok_4_20_beta_non_reasoning_cost_calculation(self): + """Test cost calculation for grok-4.20-beta-0309-non-reasoning model.""" + usage = Usage(prompt_tokens=50, completion_tokens=100, total_tokens=150) + + prompt_cost, completion_cost = cost_per_token( + model="grok-4.20-beta-0309-non-reasoning", usage=usage + ) + + # Input: 50 tokens * $2e-6 = $0.0001 + # Output: 100 tokens * $6e-6 = $0.0006 + expected_prompt_cost = 50 * 2e-6 + expected_completion_cost = 100 * 6e-6 + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-10) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-10) + + def test_grok_4_20_multi_agent_cost_calculation(self): + """Test cost calculation for grok-4.20-multi-agent-beta-0309 model.""" + usage = Usage(prompt_tokens=200, completion_tokens=300, total_tokens=500) + + prompt_cost, completion_cost = cost_per_token( + model="grok-4.20-multi-agent-beta-0309", usage=usage + ) + + # Input: 200 tokens * $2e-6 = $0.0004 + # Output: 300 tokens * $6e-6 = $0.0018 + expected_prompt_cost = 200 * 2e-6 + expected_completion_cost = 300 * 6e-6 + + assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-10) + assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-10) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_hook_extra_headers.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_hook_extra_headers.py new file mode 100644 index 0000000000..32f3a34085 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_hook_extra_headers.py @@ -0,0 +1,707 @@ +""" +Tests for pre_mcp_call guardrail hook header mutation support. + +Validates that: +1. _convert_mcp_hook_response_to_kwargs extracts extra_headers from hook response +2. pre_call_tool_check returns hook-provided extra_headers AND modified arguments +3. call_tool flows hook headers and modified arguments downstream +4. Hook-provided headers take highest priority (merge after static_headers) +5. OpenAPI-backed servers log a warning and continue (skip injection) when hook headers are present +6. JWT claims are propagated in both standard and virtual-key fast paths +7. Backward compatibility: hooks without extra_headers continue to work +""" + +import asyncio +from typing import Any, Dict, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.utils import ProxyLogging +from litellm.types.mcp import MCPAuth, MCPTransport +from litellm.types.mcp_server.mcp_server_manager import MCPServer + + +class TestConvertMcpHookResponseToKwargs: + """Tests for ProxyLogging._convert_mcp_hook_response_to_kwargs""" + + def setup_method(self): + self.proxy_logging = ProxyLogging(user_api_key_cache=MagicMock()) + + def test_returns_original_kwargs_when_response_is_none(self): + original = {"arguments": {"key": "val"}, "name": "tool"} + result = self.proxy_logging._convert_mcp_hook_response_to_kwargs( + None, original + ) + assert result == original + + def test_returns_original_kwargs_when_response_is_empty_dict(self): + original = {"arguments": {"key": "val"}} + result = self.proxy_logging._convert_mcp_hook_response_to_kwargs({}, original) + assert result == original + + def test_extracts_modified_arguments(self): + original = {"arguments": {"old": "value"}} + response = {"modified_arguments": {"new": "value"}} + result = self.proxy_logging._convert_mcp_hook_response_to_kwargs( + response, original + ) + assert result["arguments"] == {"new": "value"} + + def test_extracts_extra_headers(self): + original = {"arguments": {"key": "val"}} + response = {"extra_headers": {"Authorization": "Bearer signed-jwt"}} + result = self.proxy_logging._convert_mcp_hook_response_to_kwargs( + response, original + ) + assert result["extra_headers"] == {"Authorization": "Bearer signed-jwt"} + + def test_extracts_both_arguments_and_headers(self): + original = {"arguments": {"old": "value"}} + response = { + "modified_arguments": {"new": "value"}, + "extra_headers": {"X-Custom": "header-val"}, + } + result = self.proxy_logging._convert_mcp_hook_response_to_kwargs( + response, original + ) + assert result["arguments"] == {"new": "value"} + assert result["extra_headers"] == {"X-Custom": "header-val"} + + def test_no_extra_headers_key_preserves_original(self): + """Backward compat: hooks that only return modified_arguments still work.""" + original = {"arguments": {"key": "val"}} + response = {"modified_arguments": {"key": "new_val"}} + result = self.proxy_logging._convert_mcp_hook_response_to_kwargs( + response, original + ) + assert "extra_headers" not in result + assert result["arguments"] == {"key": "new_val"} + + def test_empty_extra_headers_not_set(self): + """Empty dict for extra_headers is falsy and should not be set.""" + original = {"arguments": {"key": "val"}} + response = {"extra_headers": {}} + result = self.proxy_logging._convert_mcp_hook_response_to_kwargs( + response, original + ) + assert "extra_headers" not in result + + +class TestPreCallToolCheckReturnsHeaders: + """Tests that pre_call_tool_check returns hook-provided headers.""" + + def _make_server(self, name="test_server"): + return MCPServer( + server_id="test-id", + name=name, + server_name=name, + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + ) + + @pytest.mark.asyncio + async def test_returns_empty_dict_when_hook_has_no_headers(self): + manager = MCPServerManager() + server = self._make_server() + + proxy_logging = MagicMock(spec=ProxyLogging) + proxy_logging._create_mcp_request_object_from_kwargs = MagicMock( + return_value=MagicMock() + ) + proxy_logging._convert_mcp_to_llm_format = MagicMock( + return_value={"model": "fake"} + ) + proxy_logging.pre_call_hook = AsyncMock( + return_value={"modified_arguments": {"key": "val"}} + ) + proxy_logging._convert_mcp_hook_response_to_kwargs = MagicMock( + return_value={"arguments": {"key": "val"}} + ) + + with patch.object(manager, "check_allowed_or_banned_tools", return_value=True): + with patch.object( + manager, + "check_tool_permission_for_key_team", + new_callable=AsyncMock, + ): + with patch.object(manager, "validate_allowed_params"): + result = await manager.pre_call_tool_check( + name="test_tool", + arguments={"key": "val"}, + server_name="test_server", + user_api_key_auth=None, + proxy_logging_obj=proxy_logging, + server=server, + ) + + assert result == {} + + @pytest.mark.asyncio + async def test_returns_extra_headers_from_hook(self): + manager = MCPServerManager() + server = self._make_server() + + hook_headers = {"Authorization": "Bearer signed-jwt", "X-Trace-Id": "abc123"} + + proxy_logging = MagicMock(spec=ProxyLogging) + proxy_logging._create_mcp_request_object_from_kwargs = MagicMock( + return_value=MagicMock() + ) + proxy_logging._convert_mcp_to_llm_format = MagicMock( + return_value={"model": "fake"} + ) + proxy_logging.pre_call_hook = AsyncMock( + return_value={"extra_headers": hook_headers} + ) + proxy_logging._convert_mcp_hook_response_to_kwargs = MagicMock( + return_value={"arguments": {"key": "val"}, "extra_headers": hook_headers} + ) + + with patch.object(manager, "check_allowed_or_banned_tools", return_value=True): + with patch.object( + manager, + "check_tool_permission_for_key_team", + new_callable=AsyncMock, + ): + with patch.object(manager, "validate_allowed_params"): + result = await manager.pre_call_tool_check( + name="test_tool", + arguments={"key": "val"}, + server_name="test_server", + user_api_key_auth=None, + proxy_logging_obj=proxy_logging, + server=server, + ) + + assert result["extra_headers"] == hook_headers + + @pytest.mark.asyncio + async def test_returns_empty_dict_when_hook_returns_none(self): + manager = MCPServerManager() + server = self._make_server() + + proxy_logging = MagicMock(spec=ProxyLogging) + proxy_logging._create_mcp_request_object_from_kwargs = MagicMock( + return_value=MagicMock() + ) + proxy_logging._convert_mcp_to_llm_format = MagicMock( + return_value={"model": "fake"} + ) + proxy_logging.pre_call_hook = AsyncMock(return_value=None) + + with patch.object(manager, "check_allowed_or_banned_tools", return_value=True): + with patch.object( + manager, + "check_tool_permission_for_key_team", + new_callable=AsyncMock, + ): + with patch.object(manager, "validate_allowed_params"): + result = await manager.pre_call_tool_check( + name="test_tool", + arguments={"key": "val"}, + server_name="test_server", + user_api_key_auth=None, + proxy_logging_obj=proxy_logging, + server=server, + ) + + assert result == {} + + @pytest.mark.asyncio + async def test_returns_modified_arguments_from_hook(self): + """Modified arguments from the hook must be returned so the caller can use them.""" + manager = MCPServerManager() + server = self._make_server() + + original_args = {"key": "original"} + modified_args = {"key": "modified", "extra": "added"} + + proxy_logging = MagicMock(spec=ProxyLogging) + proxy_logging._create_mcp_request_object_from_kwargs = MagicMock( + return_value=MagicMock() + ) + proxy_logging._convert_mcp_to_llm_format = MagicMock( + return_value={"model": "fake"} + ) + proxy_logging.pre_call_hook = AsyncMock( + return_value={"modified_arguments": modified_args} + ) + proxy_logging._convert_mcp_hook_response_to_kwargs = MagicMock( + return_value={"arguments": modified_args} + ) + + with patch.object(manager, "check_allowed_or_banned_tools", return_value=True): + with patch.object( + manager, + "check_tool_permission_for_key_team", + new_callable=AsyncMock, + ): + with patch.object(manager, "validate_allowed_params"): + result = await manager.pre_call_tool_check( + name="test_tool", + arguments=original_args, + server_name="test_server", + user_api_key_auth=None, + proxy_logging_obj=proxy_logging, + server=server, + ) + + assert result["arguments"] == modified_args + + @pytest.mark.asyncio + async def test_returns_both_modified_arguments_and_headers(self): + """Hook can modify both arguments and inject headers simultaneously.""" + manager = MCPServerManager() + server = self._make_server() + + modified_args = {"key": "modified"} + hook_headers = {"Authorization": "Bearer jwt"} + + proxy_logging = MagicMock(spec=ProxyLogging) + proxy_logging._create_mcp_request_object_from_kwargs = MagicMock( + return_value=MagicMock() + ) + proxy_logging._convert_mcp_to_llm_format = MagicMock( + return_value={"model": "fake"} + ) + proxy_logging.pre_call_hook = AsyncMock(return_value={"dummy": True}) + proxy_logging._convert_mcp_hook_response_to_kwargs = MagicMock( + return_value={"arguments": modified_args, "extra_headers": hook_headers} + ) + + with patch.object(manager, "check_allowed_or_banned_tools", return_value=True): + with patch.object( + manager, + "check_tool_permission_for_key_team", + new_callable=AsyncMock, + ): + with patch.object(manager, "validate_allowed_params"): + result = await manager.pre_call_tool_check( + name="test_tool", + arguments={"key": "original"}, + server_name="test_server", + user_api_key_auth=None, + proxy_logging_obj=proxy_logging, + server=server, + ) + + assert result["arguments"] == modified_args + assert result["extra_headers"] == hook_headers + + +class TestCallToolFlowsHookHeaders: + """Tests that call_tool passes hook_extra_headers to _call_regular_mcp_tool.""" + + def _make_server(self, name="test_server"): + return MCPServer( + server_id="test-id", + name=name, + server_name=name, + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + ) + + @pytest.mark.asyncio + async def test_hook_headers_passed_to_call_regular_mcp_tool(self): + """Verify that hook_extra_headers kwarg is forwarded.""" + manager = MCPServerManager() + server = self._make_server() + + hook_headers = {"Authorization": "Bearer signed-jwt"} + + with patch.object( + manager, + "_get_mcp_server_from_tool_name", + return_value=server, + ): + with patch.object( + manager, + "pre_call_tool_check", + new_callable=AsyncMock, + return_value={"extra_headers": hook_headers}, + ): + with patch.object( + manager, + "_create_during_hook_task", + return_value=asyncio.create_task(asyncio.sleep(0)), + ): + with patch.object( + manager, + "_call_regular_mcp_tool", + new_callable=AsyncMock, + return_value=MagicMock(), + ) as mock_call: + proxy_logging = MagicMock(spec=ProxyLogging) + + await manager.call_tool( + server_name="test_server", + name="test_tool", + arguments={"key": "val"}, + proxy_logging_obj=proxy_logging, + ) + + mock_call.assert_called_once() + call_kwargs = mock_call.call_args + assert call_kwargs.kwargs.get("hook_extra_headers") == hook_headers + + @pytest.mark.asyncio + async def test_no_hook_headers_when_no_proxy_logging(self): + """Without proxy_logging_obj, no pre_call_tool_check runs.""" + manager = MCPServerManager() + server = self._make_server() + + with patch.object( + manager, + "_get_mcp_server_from_tool_name", + return_value=server, + ): + with patch.object( + manager, + "_call_regular_mcp_tool", + new_callable=AsyncMock, + return_value=MagicMock(), + ) as mock_call: + await manager.call_tool( + server_name="test_server", + name="test_tool", + arguments={"key": "val"}, + proxy_logging_obj=None, + ) + + mock_call.assert_called_once() + call_kwargs = mock_call.call_args + assert call_kwargs.kwargs.get("hook_extra_headers") is None + + @pytest.mark.asyncio + async def test_modified_arguments_passed_to_downstream(self): + """Hook-modified arguments must be used for the actual tool call.""" + manager = MCPServerManager() + server = self._make_server() + + modified_args = {"key": "modified_by_hook"} + + with patch.object( + manager, + "_get_mcp_server_from_tool_name", + return_value=server, + ): + with patch.object( + manager, + "pre_call_tool_check", + new_callable=AsyncMock, + return_value={"arguments": modified_args}, + ): + with patch.object( + manager, + "_create_during_hook_task", + return_value=asyncio.create_task(asyncio.sleep(0)), + ): + with patch.object( + manager, + "_call_regular_mcp_tool", + new_callable=AsyncMock, + return_value=MagicMock(), + ) as mock_call: + proxy_logging = MagicMock(spec=ProxyLogging) + + await manager.call_tool( + server_name="test_server", + name="test_tool", + arguments={"key": "original"}, + proxy_logging_obj=proxy_logging, + ) + + mock_call.assert_called_once() + call_kwargs = mock_call.call_args + assert call_kwargs.kwargs.get("arguments") == modified_args + + @pytest.mark.asyncio + async def test_openapi_server_warns_and_continues_on_hook_headers(self): + """OpenAPI-backed servers log a warning and continue when hook injects headers.""" + manager = MCPServerManager() + server = MCPServer( + server_id="test-id", + name="openapi_server", + server_name="openapi_server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + spec_path="/path/to/spec.yaml", + ) + + with patch.object( + manager, "_get_mcp_server_from_tool_name", return_value=server + ): + with patch.object( + manager, + "pre_call_tool_check", + new_callable=AsyncMock, + return_value={"extra_headers": {"Authorization": "Bearer jwt"}}, + ): + with patch.object( + manager, + "_create_during_hook_task", + return_value=asyncio.create_task(asyncio.sleep(0)), + ): + with patch.object( + manager, + "_call_openapi_tool_handler", + new_callable=AsyncMock, + return_value=MagicMock(), + ): + import litellm.proxy._experimental.mcp_server.mcp_server_manager as mgr_mod + + proxy_logging = MagicMock(spec=ProxyLogging) + + with patch.object(mgr_mod, "verbose_logger") as mock_logger: + # Should NOT raise — just warn and proceed + await manager.call_tool( + server_name="openapi_server", + name="test_tool", + arguments={}, + proxy_logging_obj=proxy_logging, + ) + mock_logger.warning.assert_called_once() + assert "header injection is not supported" in mock_logger.warning.call_args[0][0] + + @pytest.mark.asyncio + async def test_openapi_server_no_error_without_hook_headers(self): + """No exception when OpenAPI server has no hook-injected headers.""" + manager = MCPServerManager() + server = MCPServer( + server_id="test-id", + name="openapi_server", + server_name="openapi_server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + spec_path="/path/to/spec.yaml", + ) + + with patch.object( + manager, "_get_mcp_server_from_tool_name", return_value=server + ): + with patch.object( + manager, + "pre_call_tool_check", + new_callable=AsyncMock, + return_value={}, + ): + with patch.object( + manager, + "_create_during_hook_task", + return_value=asyncio.create_task(asyncio.sleep(0)), + ): + with patch.object( + manager, + "_call_openapi_tool_handler", + new_callable=AsyncMock, + return_value=MagicMock(), + ): + proxy_logging = MagicMock(spec=ProxyLogging) + + await manager.call_tool( + server_name="openapi_server", + name="test_tool", + arguments={}, + proxy_logging_obj=proxy_logging, + ) + + +class TestHookHeaderMergePriority: + """Tests that hook-provided headers have highest priority in _call_regular_mcp_tool.""" + + def _make_server( + self, + static_headers: Optional[Dict[str, str]] = None, + extra_headers_config: Optional[list] = None, + ): + return MCPServer( + server_id="test-id", + name="Test Server", + server_name="test_server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + static_headers=static_headers, + extra_headers=extra_headers_config, + ) + + @pytest.mark.asyncio + async def test_hook_headers_override_static_headers(self): + """Hook headers should take precedence over static_headers.""" + manager = MCPServerManager() + server = self._make_server( + static_headers={"Authorization": "Bearer static-token", "X-Static": "yes"} + ) + + hook_headers = {"Authorization": "Bearer hook-signed-jwt"} + + captured_extra_headers: Dict[str, Any] = {} + + async def fake_create_mcp_client( + server, mcp_auth_header=None, extra_headers=None, stdio_env=None + ): + captured_extra_headers["value"] = extra_headers + mock_client = MagicMock() + mock_client.call_tool = AsyncMock(return_value=MagicMock()) + return mock_client + + with patch.object( + manager, "_create_mcp_client", side_effect=fake_create_mcp_client + ): + with patch.object(manager, "_build_stdio_env", return_value=None): + try: + await manager._call_regular_mcp_tool( + mcp_server=server, + original_tool_name="test_tool", + arguments={"key": "val"}, + tasks=[], + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers=None, + raw_headers=None, + proxy_logging_obj=None, + hook_extra_headers=hook_headers, + ) + except Exception: + pass + + headers = captured_extra_headers.get("value", {}) + assert headers["Authorization"] == "Bearer hook-signed-jwt" + assert headers["X-Static"] == "yes" + + @pytest.mark.asyncio + async def test_no_hook_headers_preserves_existing_behavior(self): + """When hook_extra_headers is None, existing header logic is unchanged.""" + manager = MCPServerManager() + server = self._make_server( + static_headers={"X-Static": "static-value"} + ) + + captured_extra_headers: Dict[str, Any] = {} + + async def fake_create_mcp_client( + server, mcp_auth_header=None, extra_headers=None, stdio_env=None + ): + captured_extra_headers["value"] = extra_headers + mock_client = MagicMock() + mock_client.call_tool = AsyncMock(return_value=MagicMock()) + return mock_client + + with patch.object( + manager, "_create_mcp_client", side_effect=fake_create_mcp_client + ): + with patch.object(manager, "_build_stdio_env", return_value=None): + try: + await manager._call_regular_mcp_tool( + mcp_server=server, + original_tool_name="test_tool", + arguments={"key": "val"}, + tasks=[], + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers=None, + raw_headers=None, + proxy_logging_obj=None, + hook_extra_headers=None, + ) + except Exception: + pass + + headers = captured_extra_headers.get("value", {}) + assert headers == {"X-Static": "static-value"} + + @pytest.mark.asyncio + async def test_hook_headers_merge_with_oauth2(self): + """Hook headers merge on top of OAuth2 headers.""" + manager = MCPServerManager() + server = MCPServer( + server_id="test-id", + name="Test Server", + server_name="test_server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + ) + + captured_extra_headers: Dict[str, Any] = {} + + async def fake_create_mcp_client( + server, mcp_auth_header=None, extra_headers=None, stdio_env=None + ): + captured_extra_headers["value"] = extra_headers + mock_client = MagicMock() + mock_client.call_tool = AsyncMock(return_value=MagicMock()) + return mock_client + + with patch.object( + manager, "_create_mcp_client", side_effect=fake_create_mcp_client + ): + with patch.object(manager, "_build_stdio_env", return_value=None): + try: + await manager._call_regular_mcp_tool( + mcp_server=server, + original_tool_name="test_tool", + arguments={"key": "val"}, + tasks=[], + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers={ + "Authorization": "Bearer oauth2-token", + "X-OAuth": "yes", + }, + raw_headers=None, + proxy_logging_obj=None, + hook_extra_headers={ + "Authorization": "Bearer hook-jwt", + "X-Trace-Id": "trace-123", + }, + ) + except Exception: + pass + + headers = captured_extra_headers.get("value", {}) + assert headers["Authorization"] == "Bearer hook-jwt" + assert headers["X-OAuth"] == "yes" + assert headers["X-Trace-Id"] == "trace-123" + + +class TestUserAPIKeyAuthJwtClaims: + """Tests that UserAPIKeyAuth correctly carries jwt_claims.""" + + def test_jwt_claims_field_defaults_to_none(self): + auth = UserAPIKeyAuth(api_key="test-key") + assert auth.jwt_claims is None + + def test_jwt_claims_field_accepts_dict(self): + claims = {"sub": "user-123", "iss": "litellm", "exp": 9999999999} + auth = UserAPIKeyAuth(api_key="test-key", jwt_claims=claims) + assert auth.jwt_claims == claims + assert auth.jwt_claims["sub"] == "user-123" + + def test_jwt_claims_backward_compatible_without_field(self): + """Existing code that doesn't pass jwt_claims should still work.""" + auth = UserAPIKeyAuth( + api_key="test-key", + user_id="user-1", + team_id="team-1", + ) + assert auth.jwt_claims is None + assert auth.user_id == "user-1" + + def test_jwt_claims_set_after_construction(self): + """Virtual-key fast path sets jwt_claims after the object is created.""" + auth = UserAPIKeyAuth(api_key="test-key") + assert auth.jwt_claims is None + + claims = {"sub": "user-456", "iss": "okta", "groups": ["admin"]} + auth.jwt_claims = claims + assert auth.jwt_claims == claims + assert auth.jwt_claims["groups"] == ["admin"] diff --git a/tests/test_litellm/proxy/guardrails/test_mcp_jwt_signer.py b/tests/test_litellm/proxy/guardrails/test_mcp_jwt_signer.py new file mode 100644 index 0000000000..247fe7b576 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/test_mcp_jwt_signer.py @@ -0,0 +1,1103 @@ +""" +Tests for the MCPJWTSigner built-in guardrail. + +Tests cover: + - RSA key generation and loading + - JWT signing and JWKS format + - Claim building (sub, act, scope) + - Hook fires for call_mcp_tool, skips other call types + - get_mcp_jwt_signer() singleton pattern +""" + +import base64 +import time +from typing import Any, Dict, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_user_api_key_dict( + user_id: str = "user-123", + team_id: str = "team-abc", + user_email: str = "user@example.com", + end_user_id: Optional[str] = None, +) -> MagicMock: + mock = MagicMock() + mock.user_id = user_id + mock.team_id = team_id + mock.user_email = user_email + mock.end_user_id = end_user_id + mock.org_id = None + mock.token = None + mock.api_key = None + # Explicit None so MagicMock doesn't auto-create a truthy proxy attribute + mock.jwt_claims = None + return mock + + +def _decode_unverified(token: str) -> Dict[str, Any]: + return jwt.decode(token, options={"verify_signature": False}) + + +# --------------------------------------------------------------------------- +# Import target (inline so we can reset the singleton between tests) +# --------------------------------------------------------------------------- + + +def _make_signer(**kwargs: Any): + # Reset singleton before each signer creation to avoid cross-test pollution + import litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer as mod + + mod._mcp_jwt_signer_instance = None + + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( + MCPJWTSigner, + ) + + return MCPJWTSigner( + guardrail_name="test-jwt-signer", + event_hook="pre_mcp_call", + default_on=True, + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# Key generation tests +# --------------------------------------------------------------------------- + + +def test_auto_generates_rsa_keypair(): + """MCPJWTSigner auto-generates an RSA-2048 keypair when env var is unset.""" + signer = _make_signer() + assert signer._private_key is not None + assert signer._public_key is not None + assert signer._kid is not None and len(signer._kid) == 16 + + +def test_kid_is_deterministic(): + """Two signers built from the same key have the same kid.""" + signer1 = _make_signer() + private_pem = signer1._private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + with patch.dict("os.environ", {"MCP_JWT_SIGNING_KEY": private_pem}): + signer2 = _make_signer() + + assert signer1._kid == signer2._kid + + +def test_load_key_from_env_var(): + """MCPJWTSigner loads a user-provided RSA key from the env var.""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + with patch.dict("os.environ", {"MCP_JWT_SIGNING_KEY": pem}): + signer = _make_signer() + + assert signer._kid is not None + + +# --------------------------------------------------------------------------- +# JWKS tests +# --------------------------------------------------------------------------- + + +def test_get_jwks_format(): + """get_jwks() returns a valid JWKS dict with RSA fields.""" + signer = _make_signer() + jwks = signer.get_jwks() + + assert "keys" in jwks + assert len(jwks["keys"]) == 1 + key = jwks["keys"][0] + + assert key["kty"] == "RSA" + assert key["alg"] == "RS256" + assert key["use"] == "sig" + assert key["kid"] == signer._kid + assert "n" in key and len(key["n"]) > 0 + assert "e" in key and key["e"] == "AQAB" # 65537 in base64url + + +def test_jwks_public_key_can_verify_signed_jwt(): + """A JWT signed by MCPJWTSigner can be verified using the JWKS public key.""" + signer = _make_signer(issuer="https://litellm.example.com", audience="mcp") + now = int(time.time()) + claims = {"iss": "https://litellm.example.com", "aud": "mcp", "iat": now, "exp": now + 300} + + token = jwt.encode(claims, signer._private_key, algorithm="RS256") + + # Reconstruct public key from JWKS + jwks = signer.get_jwks() + key_data = jwks["keys"][0] + n = int.from_bytes(base64.urlsafe_b64decode(key_data["n"] + "=="), byteorder="big") + e = int.from_bytes(base64.urlsafe_b64decode(key_data["e"] + "=="), byteorder="big") + from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers + pub_key = RSAPublicNumbers(e=e, n=n).public_key() + + decoded = jwt.decode( + token, + pub_key, + algorithms=["RS256"], + audience="mcp", + issuer="https://litellm.example.com", + ) + assert decoded["iss"] == "https://litellm.example.com" + + +# --------------------------------------------------------------------------- +# Claim building tests +# --------------------------------------------------------------------------- + + +def test_build_claims_standard_fields(): + """_build_claims() populates iss, aud, iat, exp, nbf.""" + signer = _make_signer(issuer="https://litellm.example.com", audience="mcp", ttl_seconds=300) + user_dict = _make_user_api_key_dict() + data = {"mcp_tool_name": "get_weather"} + + claims = signer._build_claims(user_dict, data) + + assert claims["iss"] == "https://litellm.example.com" + assert claims["aud"] == "mcp" + assert "iat" in claims + assert "exp" in claims + assert claims["exp"] - claims["iat"] == 300 + assert "nbf" in claims + + +def test_build_claims_identity(): + """_build_claims() sets sub from user_id and act from team_id (RFC 8693).""" + signer = _make_signer() + user_dict = _make_user_api_key_dict(user_id="user-xyz", team_id="team-eng") + data: Dict[str, Any] = {} + + claims = signer._build_claims(user_dict, data) + + assert claims["sub"] == "user-xyz" + assert claims["act"]["sub"] == "team-eng" + assert claims["email"] == "user@example.com" + + +def test_build_claims_scope_with_tool(): + """_build_claims() encodes tool-specific scope when mcp_tool_name is set.""" + signer = _make_signer() + user_dict = _make_user_api_key_dict() + data = {"mcp_tool_name": "search_web"} + + claims = signer._build_claims(user_dict, data) + + scopes = set(claims["scope"].split()) + assert "mcp:tools/call" in scopes + assert "mcp:tools/search_web:call" in scopes + # Tool-call JWTs must NOT carry mcp:tools/list — least-privilege + assert "mcp:tools/list" not in scopes + + +def test_build_claims_scope_without_tool(): + """_build_claims() includes mcp:tools/list when no specific tool is called.""" + signer = _make_signer() + user_dict = _make_user_api_key_dict() + data: Dict[str, Any] = {} + + claims = signer._build_claims(user_dict, data) + + scopes = set(claims["scope"].split()) + assert "mcp:tools/call" in scopes + assert "mcp:tools/list" in scopes + # No per-tool call scope when no tool name was given + assert not any(s.endswith(":call") and s != "mcp:tools/call" for s in scopes) + + +def test_build_claims_act_fallback_to_litellm_proxy(): + """_build_claims() falls back to 'litellm-proxy' when team_id and org_id are absent.""" + signer = _make_signer() + user_dict = _make_user_api_key_dict() + user_dict.team_id = None + user_dict.org_id = None + + claims = signer._build_claims(user_dict, {}) + + assert claims["act"]["sub"] == "litellm-proxy" + + +def test_build_claims_sub_fallback_to_token_hash(): + """_build_claims() sets sub to an apikey: hash when user_id is absent.""" + signer = _make_signer() + user_dict = _make_user_api_key_dict(user_id="") + user_dict.user_id = None + user_dict.token = "sk-test-api-key-abc123" + + claims = signer._build_claims(user_dict, {}) + + assert claims["sub"].startswith("apikey:") + assert len(claims["sub"]) == len("apikey:") + 16 # sha256 hex[:16] + + +def test_build_claims_sub_fallback_to_litellm_proxy_when_no_token(): + """_build_claims() falls back to 'litellm-proxy' when user_id and token are both absent.""" + signer = _make_signer() + user_dict = _make_user_api_key_dict(user_id="") + user_dict.user_id = None + user_dict.token = None + user_dict.api_key = None + + claims = signer._build_claims(user_dict, {}) + + assert claims["sub"] == "litellm-proxy" + + +def test_init_raises_on_zero_ttl(): + """MCPJWTSigner raises ValueError when ttl_seconds is 0.""" + with pytest.raises(ValueError, match="ttl_seconds must be > 0"): + _make_signer(ttl_seconds=0) + + +def test_init_raises_on_negative_ttl(): + """MCPJWTSigner raises ValueError when ttl_seconds is negative.""" + with pytest.raises(ValueError, match="ttl_seconds must be > 0"): + _make_signer(ttl_seconds=-60) + + +def test_jwks_max_age_persistent_key(): + """jwks_max_age is 3600 when key loaded from env var.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa as crsa + + private_key = crsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + with patch.dict("os.environ", {"MCP_JWT_SIGNING_KEY": pem}): + signer = _make_signer() + + assert signer.jwks_max_age == 3600 + + +def test_jwks_max_age_auto_generated_key(): + """jwks_max_age is 300 for auto-generated (ephemeral) keys.""" + signer = _make_signer() + assert signer.jwks_max_age == 300 + + +# --------------------------------------------------------------------------- +# Hook dispatch tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_hook_fires_for_call_mcp_tool(): + """async_pre_call_hook() injects Authorization header for call_mcp_tool.""" + signer = _make_signer(issuer="https://litellm.example.com", audience="mcp") + user_dict = _make_user_api_key_dict() + data = {"mcp_tool_name": "do_thing"} + + result = await signer.async_pre_call_hook( + user_api_key_dict=user_dict, + cache=MagicMock(), + data=data, + call_type="call_mcp_tool", + ) + + assert isinstance(result, dict) + assert "extra_headers" in result + assert result["extra_headers"]["Authorization"].startswith("Bearer ") + + +@pytest.mark.asyncio +async def test_hook_skips_non_mcp_call_types(): + """async_pre_call_hook() leaves data unchanged for non-MCP call types.""" + signer = _make_signer() + user_dict = _make_user_api_key_dict() + data = {"messages": [{"role": "user", "content": "hello"}]} + + for call_type in ("completion", "acompletion", "embedding", "list_mcp_tools"): + original_data = {**data} + result = await signer.async_pre_call_hook( + user_api_key_dict=user_dict, + cache=MagicMock(), + data=original_data, + call_type=call_type, # type: ignore[arg-type] + ) + assert "extra_headers" not in (result or {}), f"extra_headers should not be set for {call_type}" + + +@pytest.mark.asyncio +async def test_signed_token_is_verifiable(): + """The JWT injected by the hook can be verified against the JWKS public key.""" + signer = _make_signer(issuer="https://litellm.example.com", audience="mcp", ttl_seconds=300) + user_dict = _make_user_api_key_dict(user_id="alice", team_id="backend") + data = {"mcp_tool_name": "search"} + + result = await signer.async_pre_call_hook( + user_api_key_dict=user_dict, + cache=MagicMock(), + data=data, + call_type="call_mcp_tool", + ) + + assert isinstance(result, dict) + token = result["extra_headers"]["Authorization"].removeprefix("Bearer ") + + decoded = _decode_unverified(token) + assert decoded["sub"] == "alice" + assert decoded["act"]["sub"] == "backend" + assert "mcp:tools/search:call" in decoded["scope"] + assert decoded["iss"] == "https://litellm.example.com" + assert decoded["aud"] == "mcp" + + +# --------------------------------------------------------------------------- +# Singleton tests +# --------------------------------------------------------------------------- + + +def test_get_mcp_jwt_signer_returns_none_before_init(): + """get_mcp_jwt_signer() returns None before any MCPJWTSigner is created.""" + import litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer as mod + + mod._mcp_jwt_signer_instance = None + + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( + get_mcp_jwt_signer, + ) + + assert get_mcp_jwt_signer() is None + + +def test_get_mcp_jwt_signer_returns_instance_after_init(): + """get_mcp_jwt_signer() returns the initialized signer instance.""" + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import ( + get_mcp_jwt_signer, + ) + + signer = _make_signer() + assert get_mcp_jwt_signer() is signer + + +# --------------------------------------------------------------------------- +# FR-10: Configurable scopes +# --------------------------------------------------------------------------- + + +def test_allowed_scopes_replaces_auto_generation(): + """When allowed_scopes is set it is used verbatim instead of auto-generating.""" + signer = _make_signer(allowed_scopes=["mcp:admin", "mcp:tools/call"]) + user_dict = _make_user_api_key_dict() + data = {"mcp_tool_name": "some_tool"} + + claims = signer._build_claims(user_dict, data) + + assert claims["scope"] == "mcp:admin mcp:tools/call" + + +def test_tool_call_scope_no_list_permission(): + """Tool-call JWTs must NOT carry mcp:tools/list (least-privilege).""" + signer = _make_signer() + user_dict = _make_user_api_key_dict() + data = {"mcp_tool_name": "my_tool"} + + claims = signer._build_claims(user_dict, data) + + scopes = set(claims["scope"].split()) + assert "mcp:tools/list" not in scopes + assert "mcp:tools/call" in scopes + assert "mcp:tools/my_tool:call" in scopes + + +# --------------------------------------------------------------------------- +# FR-12: End-user identity mapping +# --------------------------------------------------------------------------- + + +def test_end_user_claim_sources_token_sub(): + """end_user_claim_sources resolves sub from incoming JWT claims.""" + signer = _make_signer(end_user_claim_sources=["token:sub", "litellm:user_id"]) + user_dict = _make_user_api_key_dict(user_id="litellm-user") + jwt_claims = {"sub": "idp-user-123", "email": "idp@example.com"} + + claims = signer._build_claims(user_dict, {}, jwt_claims=jwt_claims) + + assert claims["sub"] == "idp-user-123" + + +def test_end_user_claim_sources_falls_back_to_litellm_user_id(): + """Falls back to litellm:user_id when token:sub is absent.""" + signer = _make_signer(end_user_claim_sources=["token:sub", "litellm:user_id"]) + user_dict = _make_user_api_key_dict(user_id="litellm-user") + jwt_claims: Dict[str, Any] = {} # no sub + + claims = signer._build_claims(user_dict, {}, jwt_claims=jwt_claims) + + assert claims["sub"] == "litellm-user" + + +def test_end_user_claim_sources_email_source(): + """token:email resolves correctly.""" + signer = _make_signer(end_user_claim_sources=["token:email"]) + user_dict = _make_user_api_key_dict(user_id="") + user_dict.user_id = None + jwt_claims = {"email": "alice@corp.com"} + + claims = signer._build_claims(user_dict, {}, jwt_claims=jwt_claims) + + assert claims["sub"] == "alice@corp.com" + + +def test_end_user_claim_sources_litellm_email(): + """litellm:email resolves from UserAPIKeyAuth.user_email.""" + signer = _make_signer(end_user_claim_sources=["litellm:email"]) + user_dict = _make_user_api_key_dict(user_email="proxy-user@example.com") + user_dict.user_id = None + + claims = signer._build_claims(user_dict, {}) + + assert claims["sub"] == "proxy-user@example.com" + + +# --------------------------------------------------------------------------- +# FR-13: Claim operations +# --------------------------------------------------------------------------- + + +def test_add_claims_inserts_when_absent(): + """add_claims inserts key when it is not already in the JWT.""" + signer = _make_signer(add_claims={"deployment_id": "prod-001"}) + user_dict = _make_user_api_key_dict() + + claims = signer._build_claims(user_dict, {}) + + assert claims["deployment_id"] == "prod-001" + + +def test_add_claims_does_not_overwrite_existing(): + """add_claims does NOT overwrite an existing claim (use set_claims for that).""" + signer = _make_signer(add_claims={"iss": "should-not-win"}) + user_dict = _make_user_api_key_dict() + + claims = signer._build_claims(user_dict, {}) + + # iss should be the configured issuer, not overwritten + assert claims["iss"] != "should-not-win" + + +def test_set_claims_always_overrides(): + """set_claims always overrides computed claims.""" + signer = _make_signer(set_claims={"iss": "override-issuer", "custom": "x"}) + user_dict = _make_user_api_key_dict() + + claims = signer._build_claims(user_dict, {}) + + assert claims["iss"] == "override-issuer" + assert claims["custom"] == "x" + + +def test_remove_claims_deletes_keys(): + """remove_claims deletes specified keys from the final JWT.""" + signer = _make_signer(remove_claims=["nbf", "email"]) + user_dict = _make_user_api_key_dict() + + claims = signer._build_claims(user_dict, {}) + + assert "nbf" not in claims + assert "email" not in claims + + +def test_claim_operations_order_add_then_set_then_remove(): + """add → set → remove is applied in order: set wins over add, remove beats both.""" + signer = _make_signer( + add_claims={"x": "from-add"}, + set_claims={"x": "from-set"}, + remove_claims=["x"], + ) + user_dict = _make_user_api_key_dict() + + claims = signer._build_claims(user_dict, {}) + + assert "x" not in claims # remove wins + + +# --------------------------------------------------------------------------- +# FR-14: Two-token model +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_channel_token_injected_when_configured(): + """When channel_token_audience is set, x-mcp-channel-token header is injected.""" + signer = _make_signer( + channel_token_audience="bedrock-gateway", + channel_token_ttl=60, + ) + user_dict = _make_user_api_key_dict() + data = {"mcp_tool_name": "list_tables"} + + result = await signer.async_pre_call_hook( + user_api_key_dict=user_dict, + cache=MagicMock(), + data=data, + call_type="call_mcp_tool", + ) + + assert isinstance(result, dict) + assert "x-mcp-channel-token" in result["extra_headers"] + channel_token = result["extra_headers"]["x-mcp-channel-token"].removeprefix("Bearer ") + channel_payload = _decode_unverified(channel_token) + assert channel_payload["aud"] == "bedrock-gateway" + + +@pytest.mark.asyncio +async def test_channel_token_absent_when_not_configured(): + """x-mcp-channel-token is not injected when channel_token_audience is unset.""" + signer = _make_signer() + user_dict = _make_user_api_key_dict() + data = {"mcp_tool_name": "tool"} + + result = await signer.async_pre_call_hook( + user_api_key_dict=user_dict, + cache=MagicMock(), + data=data, + call_type="call_mcp_tool", + ) + + assert isinstance(result, dict) + assert "x-mcp-channel-token" not in result["extra_headers"] + + +# --------------------------------------------------------------------------- +# FR-15: Incoming claim validation +# --------------------------------------------------------------------------- + + +def test_required_claims_pass_when_present(): + """_validate_required_claims() passes when all required claims are present.""" + signer = _make_signer(required_claims=["sub", "email"]) + # Should not raise + signer._validate_required_claims({"sub": "user", "email": "u@example.com"}) + + +def test_required_claims_raise_403_when_missing(): + """_validate_required_claims() raises HTTP 403 when a required claim is missing.""" + from fastapi import HTTPException + + signer = _make_signer(required_claims=["sub", "email"]) + with pytest.raises(HTTPException) as exc_info: + signer._validate_required_claims({"sub": "user"}) # email missing + + assert exc_info.value.status_code == 403 + assert "email" in str(exc_info.value.detail) + + +def test_required_claims_raise_when_no_jwt_claims(): + """_validate_required_claims() raises when jwt_claims is None and claims are required.""" + from fastapi import HTTPException + + signer = _make_signer(required_claims=["sub"]) + with pytest.raises(HTTPException): + signer._validate_required_claims(None) + + +def test_optional_claims_passed_through(): + """optional_claims are forwarded from incoming jwt_claims into the outbound JWT.""" + signer = _make_signer(optional_claims=["groups", "roles"]) + user_dict = _make_user_api_key_dict() + jwt_claims = {"sub": "u", "groups": ["admin"], "roles": ["editor"]} + + claims = signer._build_claims(user_dict, {}, jwt_claims=jwt_claims) + + assert claims["groups"] == ["admin"] + assert claims["roles"] == ["editor"] + + +def test_optional_claims_not_injected_if_absent(): + """optional_claims are silently skipped when absent in incoming jwt_claims.""" + signer = _make_signer(optional_claims=["groups"]) + user_dict = _make_user_api_key_dict() + jwt_claims: Dict[str, Any] = {"sub": "u"} # no groups + + claims = signer._build_claims(user_dict, {}, jwt_claims=jwt_claims) + + assert "groups" not in claims + + +# --------------------------------------------------------------------------- +# FR-9: Debug headers +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_debug_header_injected_when_enabled(): + """x-litellm-mcp-debug header is injected when debug_headers=True.""" + signer = _make_signer(debug_headers=True) + user_dict = _make_user_api_key_dict() + data = {"mcp_tool_name": "my_tool"} + + result = await signer.async_pre_call_hook( + user_api_key_dict=user_dict, + cache=MagicMock(), + data=data, + call_type="call_mcp_tool", + ) + + assert isinstance(result, dict) + assert "x-litellm-mcp-debug" in result["extra_headers"] + debug_val = result["extra_headers"]["x-litellm-mcp-debug"] + assert "v=1" in debug_val + assert "kid=" in debug_val + assert "sub=" in debug_val + + +@pytest.mark.asyncio +async def test_debug_header_absent_when_disabled(): + """x-litellm-mcp-debug is NOT injected when debug_headers=False (default).""" + signer = _make_signer() + user_dict = _make_user_api_key_dict() + data = {"mcp_tool_name": "tool"} + + result = await signer.async_pre_call_hook( + user_api_key_dict=user_dict, + cache=MagicMock(), + data=data, + call_type="call_mcp_tool", + ) + + assert isinstance(result, dict) + assert "x-litellm-mcp-debug" not in result["extra_headers"] + + +# --------------------------------------------------------------------------- +# P1 fix: extra_headers merging (multi-guardrail chains) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_extra_headers_are_merged_not_replaced(): + """ + Existing extra_headers from a prior guardrail are preserved — only + Authorization is added/overwritten, other keys survive. + """ + signer = _make_signer() + user_dict = _make_user_api_key_dict() + # Simulate a prior guardrail having injected a tracing header + data = { + "mcp_tool_name": "list", + "extra_headers": {"x-trace-id": "abc123", "x-correlation-id": "xyz"}, + } + + result = await signer.async_pre_call_hook( + user_api_key_dict=user_dict, + cache=MagicMock(), + data=data, + call_type="call_mcp_tool", + ) + + assert isinstance(result, dict) + headers = result["extra_headers"] + # Prior headers preserved + assert headers.get("x-trace-id") == "abc123" + assert headers.get("x-correlation-id") == "xyz" + # Authorization injected + assert "Authorization" in headers + + +# --------------------------------------------------------------------------- +# FR-5: Verify + re-sign — jwt_claims fallback from UserAPIKeyAuth +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_sub_resolved_from_user_api_key_dict_jwt_claims(): + """ + When no raw token is present but UserAPIKeyAuth.jwt_claims has a sub, + the guardrail resolves sub from jwt_claims (LiteLLM-decoded JWT path). + """ + signer = _make_signer(end_user_claim_sources=["token:sub", "litellm:user_id"]) + user_dict = _make_user_api_key_dict(user_id="litellm-fallback") + # jwt_claims populated by LiteLLM's JWT auth machinery + user_dict.jwt_claims = {"sub": "idp-alice", "email": "alice@idp.com"} + data = {"mcp_tool_name": "query"} + + result = await signer.async_pre_call_hook( + user_api_key_dict=user_dict, + cache=MagicMock(), + data=data, + call_type="call_mcp_tool", + ) + + assert isinstance(result, dict) + token = result["extra_headers"]["Authorization"].removeprefix("Bearer ") + payload = _decode_unverified(token) + assert payload["sub"] == "idp-alice" + + +# --------------------------------------------------------------------------- +# initialize_guardrail factory — regression test for config.yaml wire-up +# --------------------------------------------------------------------------- + + +def test_initialize_guardrail_passes_all_params(): + """ + initialize_guardrail must wire every documented config.yaml param through + to MCPJWTSigner. Previously only issuer/audience/ttl_seconds were passed; + all FR-5/9/10/12/13/14/15 params were silently dropped. + """ + import litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer as mod + + mod._mcp_jwt_signer_instance = None + + from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer import ( + initialize_guardrail, + ) + + litellm_params = MagicMock() + litellm_params.mode = "pre_mcp_call" + litellm_params.default_on = True + litellm_params.optional_params = None + # Set every non-default param directly on litellm_params + litellm_params.issuer = "https://litellm.example.com" + litellm_params.audience = "mcp-test" + litellm_params.ttl_seconds = 120 + litellm_params.access_token_discovery_uri = "https://idp.example.com/.well-known/openid-configuration" + litellm_params.token_introspection_endpoint = "https://idp.example.com/introspect" + litellm_params.verify_issuer = "https://idp.example.com" + litellm_params.verify_audience = "api://test" + litellm_params.end_user_claim_sources = ["token:email", "litellm:user_id"] + litellm_params.add_claims = {"deployment_id": "prod"} + litellm_params.set_claims = {"env": "production"} + litellm_params.remove_claims = ["nbf"] + litellm_params.channel_token_audience = "bedrock-gateway" + litellm_params.channel_token_ttl = 60 + litellm_params.required_claims = ["sub", "email"] + litellm_params.optional_claims = ["groups"] + litellm_params.debug_headers = True + litellm_params.allowed_scopes = ["mcp:tools/call"] + + guardrail = {"guardrail_name": "mcp-jwt-signer"} + + with patch("litellm.logging_callback_manager.add_litellm_callback"): + signer = initialize_guardrail(litellm_params, guardrail) + + assert signer.issuer == "https://litellm.example.com" + assert signer.audience == "mcp-test" + assert signer.ttl_seconds == 120 + assert signer.access_token_discovery_uri == "https://idp.example.com/.well-known/openid-configuration" + assert signer.token_introspection_endpoint == "https://idp.example.com/introspect" + assert signer.verify_issuer == "https://idp.example.com" + assert signer.verify_audience == "api://test" + assert signer.end_user_claim_sources == ["token:email", "litellm:user_id"] + assert signer.add_claims == {"deployment_id": "prod"} + assert signer.set_claims == {"env": "production"} + assert signer.remove_claims == ["nbf"] + assert signer.channel_token_audience == "bedrock-gateway" + assert signer.channel_token_ttl == 60 + assert signer.required_claims == ["sub", "email"] + assert signer.optional_claims == ["groups"] + assert signer.debug_headers is True + assert signer.allowed_scopes == ["mcp:tools/call"] + + +# --------------------------------------------------------------------------- +# FR-5: _fetch_jwks, _get_oidc_discovery, _verify_incoming_jwt, +# _introspect_opaque_token +# --------------------------------------------------------------------------- + +import litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer as _signer_mod + + +def _make_httpx_response(json_body: dict, status_code: int = 200): + """Build a minimal fake httpx Response object.""" + mock_resp = MagicMock() + mock_resp.status_code = status_code + mock_resp.json.return_value = json_body + mock_resp.raise_for_status = MagicMock() + if status_code >= 400: + from httpx import HTTPStatusError, Request, Response + + mock_resp.raise_for_status.side_effect = HTTPStatusError( + "error", request=MagicMock(), response=MagicMock() + ) + return mock_resp + + +# --- _fetch_jwks --- + + +@pytest.mark.asyncio +async def test_fetch_jwks_returns_keys_and_caches(): + """_fetch_jwks returns keys from the remote JWKS URI and caches the result.""" + _signer_mod._jwks_cache.clear() + + fake_keys = [{"kty": "RSA", "kid": "k1", "n": "abc", "e": "AQAB"}] + fake_resp = _make_httpx_response({"keys": fake_keys}) + + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=fake_resp) + + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", + return_value=mock_client, + ): + keys = await _signer_mod._fetch_jwks("https://idp.example.com/jwks") + + assert keys == fake_keys + assert "https://idp.example.com/jwks" in _signer_mod._jwks_cache + _signer_mod._jwks_cache.clear() + + +@pytest.mark.asyncio +async def test_fetch_jwks_uses_cache_on_second_call(): + """_fetch_jwks returns the cached value without a second HTTP call.""" + _signer_mod._jwks_cache.clear() + fake_keys = [{"kty": "RSA", "kid": "k1"}] + _signer_mod._jwks_cache["https://idp.example.com/jwks"] = ( + fake_keys, + time.time(), + ) + + mock_client = MagicMock() + mock_client.get = AsyncMock() + + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", + return_value=mock_client, + ): + keys = await _signer_mod._fetch_jwks("https://idp.example.com/jwks") + + mock_client.get.assert_not_called() + assert keys == fake_keys + _signer_mod._jwks_cache.clear() + + +# --- _get_oidc_discovery --- + + +@pytest.mark.asyncio +async def test_get_oidc_discovery_caches_when_jwks_uri_present(): + """_get_oidc_discovery caches the doc when jwks_uri is in the response.""" + signer = _make_signer( + access_token_discovery_uri="https://idp.example.com/.well-known/openid-configuration" + ) + signer._oidc_discovery_doc = None # ensure fresh + + discovery_doc = { + "issuer": "https://idp.example.com", + "jwks_uri": "https://idp.example.com/jwks", + } + + with patch( + "litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer._fetch_oidc_discovery", + new_callable=AsyncMock, + return_value=discovery_doc, + ): + result = await signer._get_oidc_discovery() + + assert result["jwks_uri"] == "https://idp.example.com/jwks" + assert signer._oidc_discovery_doc == discovery_doc + + +@pytest.mark.asyncio +async def test_get_oidc_discovery_does_not_cache_when_jwks_uri_absent(): + """_get_oidc_discovery does NOT cache a doc that is missing jwks_uri.""" + signer = _make_signer( + access_token_discovery_uri="https://idp.example.com/.well-known/openid-configuration" + ) + signer._oidc_discovery_doc = None + + bad_doc = {"issuer": "https://idp.example.com"} # no jwks_uri + + with patch( + "litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer._fetch_oidc_discovery", + new_callable=AsyncMock, + return_value=bad_doc, + ) as mock_fetch: + result1 = await signer._get_oidc_discovery() + result2 = await signer._get_oidc_discovery() + + # Returns the bad doc each time without caching it + assert "jwks_uri" not in result1 + assert signer._oidc_discovery_doc is None # never cached + assert mock_fetch.call_count == 2 # retried on second call + + +# --- _verify_incoming_jwt --- + + +@pytest.mark.asyncio +async def test_verify_incoming_jwt_returns_payload_on_valid_token(): + """_verify_incoming_jwt decodes and returns claims from a valid JWT.""" + # Build a signer to get a real RSA key pair; use its key to mint the "incoming" JWT + signer = _make_signer( + access_token_discovery_uri="https://idp.example.com/.well-known/openid-configuration", + verify_audience="api://test", + verify_issuer="https://idp.example.com", + ) + # Mint a JWT with signer's own key — we'll pretend it came from the IdP + now = int(time.time()) + incoming_claims = { + "sub": "idp-user-42", + "iss": "https://idp.example.com", + "aud": "api://test", + "iat": now, + "exp": now + 300, + } + incoming_token = jwt.encode(incoming_claims, signer._private_key, algorithm="RS256", headers={"kid": signer._kid}) + + # Build a JWKS from the same public key so verification passes + jwks = signer.get_jwks() + + with patch.object( + signer, + "_get_oidc_discovery", + new_callable=AsyncMock, + return_value={"jwks_uri": "https://idp.example.com/jwks"}, + ): + with patch( + "litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer._fetch_jwks", + new_callable=AsyncMock, + return_value=jwks["keys"], + ): + payload = await signer._verify_incoming_jwt(incoming_token) + + assert payload["sub"] == "idp-user-42" + + +@pytest.mark.asyncio +async def test_verify_incoming_jwt_raises_on_expired_token(): + """_verify_incoming_jwt raises PyJWTError on an expired token.""" + signer = _make_signer( + access_token_discovery_uri="https://idp.example.com/.well-known/openid-configuration", + ) + expired_claims = { + "sub": "idp-user", + "iss": "https://idp.example.com", + "aud": "api://test", + "iat": int(time.time()) - 600, + "exp": int(time.time()) - 300, # expired + } + expired_token = jwt.encode(expired_claims, signer._private_key, algorithm="RS256") + jwks = signer.get_jwks() + + with patch.object( + signer, + "_get_oidc_discovery", + new_callable=AsyncMock, + return_value={"jwks_uri": "https://idp.example.com/jwks"}, + ): + with patch( + "litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer._fetch_jwks", + new_callable=AsyncMock, + return_value=jwks["keys"], + ): + with pytest.raises(jwt.PyJWTError): + await signer._verify_incoming_jwt(expired_token) + + +# --- _introspect_opaque_token --- + + +@pytest.mark.asyncio +async def test_introspect_opaque_token_returns_claims_when_active(): + """_introspect_opaque_token returns the introspection payload for active tokens.""" + signer = _make_signer( + token_introspection_endpoint="https://idp.example.com/introspect" + ) + + introspection_response = { + "active": True, + "sub": "service-account", + "scope": "read write", + } + fake_resp = _make_httpx_response(introspection_response) + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=fake_resp) + + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", + return_value=mock_client, + ): + result = await signer._introspect_opaque_token("opaque-token-abc") + + assert result["sub"] == "service-account" + assert result["active"] is True + + +@pytest.mark.asyncio +async def test_introspect_opaque_token_raises_on_inactive_token(): + """_introspect_opaque_token raises ExpiredSignatureError when active=false.""" + signer = _make_signer( + token_introspection_endpoint="https://idp.example.com/introspect" + ) + + fake_resp = _make_httpx_response({"active": False}) + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=fake_resp) + + with patch( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", + return_value=mock_client, + ): + with pytest.raises(jwt.ExpiredSignatureError): + await signer._introspect_opaque_token("opaque-token-xyz") + + +@pytest.mark.asyncio +async def test_introspect_opaque_token_raises_without_endpoint_configured(): + """_introspect_opaque_token raises ValueError when no endpoint is set.""" + signer = _make_signer() # no token_introspection_endpoint + + with pytest.raises(ValueError, match="token_introspection_endpoint"): + await signer._introspect_opaque_token("some-token") + + +# --- FR-5 end-to-end hook path --- + + +@pytest.mark.asyncio +async def test_hook_raises_401_when_jwt_verification_fails(): + """async_pre_call_hook raises HTTP 401 when incoming JWT verification fails.""" + from fastapi import HTTPException + + signer = _make_signer( + access_token_discovery_uri="https://idp.example.com/.well-known/openid-configuration" + ) + + with patch.object( + signer, + "_verify_incoming_jwt", + new_callable=AsyncMock, + side_effect=jwt.InvalidSignatureError("bad signature"), + ): + with patch.object( + signer, + "_get_oidc_discovery", + new_callable=AsyncMock, + return_value={"jwks_uri": "https://idp.example.com/jwks"}, + ): + with pytest.raises(HTTPException) as exc_info: + await signer.async_pre_call_hook( + user_api_key_dict=_make_user_api_key_dict(), + cache=MagicMock(), + data={"mcp_tool_name": "tool", "incoming_bearer_token": "hdr.pld.sig"}, + call_type="call_mcp_tool", + ) + + assert exc_info.value.status_code == 401 diff --git a/tests/test_litellm/test_setup_wizard.py b/tests/test_litellm/test_setup_wizard.py new file mode 100644 index 0000000000..e10bd893e3 --- /dev/null +++ b/tests/test_litellm/test_setup_wizard.py @@ -0,0 +1,188 @@ +"""Unit tests for litellm.setup_wizard — pure functions only, no network calls.""" + +from litellm.setup_wizard import SetupWizard, _yaml_escape + +# --------------------------------------------------------------------------- +# _yaml_escape +# --------------------------------------------------------------------------- + + +def test_yaml_escape_plain(): + assert _yaml_escape("sk-abc123") == "sk-abc123" + + +def test_yaml_escape_double_quote(): + assert _yaml_escape('sk-ab"cd') == 'sk-ab\\"cd' + + +def test_yaml_escape_backslash(): + assert _yaml_escape("sk-ab\\cd") == "sk-ab\\\\cd" + + +def test_yaml_escape_combined(): + assert _yaml_escape('ab\\"cd') == 'ab\\\\\\"cd' + + +def test_yaml_escape_newline(): + assert _yaml_escape("sk-abc\ndef") == "sk-abc\\ndef" + + +def test_yaml_escape_carriage_return(): + assert _yaml_escape("sk-abc\rdef") == "sk-abc\\rdef" + + +def test_yaml_escape_tab(): + assert _yaml_escape("sk-abc\tdef") == "sk-abc\\tdef" + + +# --------------------------------------------------------------------------- +# SetupWizard._build_config +# --------------------------------------------------------------------------- + +_OPENAI = { + "id": "openai", + "name": "OpenAI", + "env_key": "OPENAI_API_KEY", + "models": ["gpt-4o", "gpt-4o-mini"], + "test_model": "gpt-4o-mini", +} + +_ANTHROPIC = { + "id": "anthropic", + "name": "Anthropic", + "env_key": "ANTHROPIC_API_KEY", + "models": ["claude-opus-4-6"], + "test_model": "claude-haiku-4-5-20251001", +} + +_AZURE = { + "id": "azure", + "name": "Azure OpenAI", + "env_key": "AZURE_API_KEY", + "models": [], + "test_model": None, + "needs_api_base": True, + "api_base_hint": "https://.openai.azure.com/", + "api_version": "2024-07-01-preview", +} + +_OLLAMA = { + "id": "ollama", + "name": "Ollama", + "env_key": None, + "models": ["ollama/llama3.2"], + "test_model": None, + "api_base": "http://localhost:11434", +} + + +def test_build_config_basic_openai(): + config = SetupWizard._build_config( + [_OPENAI], + {"OPENAI_API_KEY": "sk-test"}, + "sk-master", + ) + assert "model_list:" in config + assert "model_name: gpt-4o" in config + assert "model: gpt-4o" in config + assert "api_key: os.environ/OPENAI_API_KEY" in config + assert 'master_key: "sk-master"' in config + + +def test_build_config_skipped_provider_omitted(): + """Provider with no key in env_vars should not appear in model_list.""" + config = SetupWizard._build_config( + [_OPENAI, _ANTHROPIC], + {"ANTHROPIC_API_KEY": "sk-ant-test"}, # OpenAI key missing + "sk-master", + ) + assert "gpt-4o" not in config + assert "claude-opus-4-6" in config + + +def test_build_config_env_vars_written_escaped(): + """API keys with special chars should be YAML-escaped.""" + config = SetupWizard._build_config( + [_OPENAI], + {"OPENAI_API_KEY": 'sk-ab"cd'}, + "sk-master", + ) + assert 'OPENAI_API_KEY: "sk-ab\\"cd"' in config + + +def test_build_config_master_key_quoted(): + """master_key must be quoted in YAML to handle special characters.""" + config = SetupWizard._build_config( + [_OPENAI], + {"OPENAI_API_KEY": "sk-test"}, + 'sk-master"special', + ) + assert 'master_key: "sk-master\\"special"' in config + + +def test_build_config_does_not_mutate_env_vars(): + """_build_config must not modify the caller's env_vars dict.""" + env_vars = { + "AZURE_API_KEY": "az-key", + "_LITELLM_AZURE_API_BASE_AZURE": "https://my.azure.com", + "_LITELLM_AZURE_DEPLOYMENT_AZURE": "my-deployment", + } + original_keys = set(env_vars.keys()) + SetupWizard._build_config([_AZURE], env_vars, "sk-master") + assert set(env_vars.keys()) == original_keys + + +def test_build_config_azure_uses_deployment_name(): + env_vars = { + "AZURE_API_KEY": "az-key", + "_LITELLM_AZURE_API_BASE_AZURE": "https://my.azure.com", + "_LITELLM_AZURE_DEPLOYMENT_AZURE": "my-gpt4o", + } + config = SetupWizard._build_config([_AZURE], env_vars, "sk-master") + assert "model: azure/my-gpt4o" in config + assert "model_name: azure-my-gpt4o" in config + # api_base must be quoted to survive YAML special chars + assert 'api_base: "https://my.azure.com"' in config + + +def test_build_config_azure_no_deployment_skipped(): + """Azure without a deployment name should emit nothing (not fallback to gpt-4o).""" + env_vars = {"AZURE_API_KEY": "az-key"} # no deployment sentinel + config = SetupWizard._build_config([_AZURE], env_vars, "sk-master") + # No azure model entry should be emitted when deployment name is absent + assert "model: azure/" not in config + + +def test_build_config_no_display_name_collision_openai_and_azure(): + """OpenAI gpt-4o and azure gpt-4o should get distinct model_name values.""" + env_vars = { + "OPENAI_API_KEY": "sk-openai", + "AZURE_API_KEY": "az-key", + "_LITELLM_AZURE_DEPLOYMENT_AZURE": "gpt-4o", + } + config = SetupWizard._build_config([_OPENAI, _AZURE], env_vars, "sk-master") + assert "model_name: gpt-4o" in config # OpenAI + assert "model_name: azure-gpt-4o" in config # Azure — qualified + + +def test_build_config_ollama_no_api_key_line(): + """Ollama has no env_key — config should not contain an api_key line for it.""" + config = SetupWizard._build_config([_OLLAMA], {}, "sk-master") + assert "ollama/llama3.2" in config + assert "api_key:" not in config + + +def test_build_config_master_key_in_general_settings(): + """master_key is written to general_settings.""" + config = SetupWizard._build_config([_OPENAI], {"OPENAI_API_KEY": "k"}, "sk-m") + assert 'master_key: "sk-m"' in config + + +def test_build_config_internal_sentinel_keys_excluded(): + """_LITELLM_ prefixed sentinel keys must not appear in environment_variables.""" + env_vars = { + "OPENAI_API_KEY": "sk-real", + "_LITELLM_AZURE_API_BASE_AZURE": "https://x.azure.com", + } + config = SetupWizard._build_config([_OPENAI], env_vars, "sk-master") + assert "_LITELLM_" not in config diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index ceb14864ad..18ab475f22 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -20,7 +20,6 @@ import { ToolOutlined, TagsOutlined, AuditOutlined, - MessageOutlined, } from "@ant-design/icons"; // import { // all_admin_roles, @@ -466,41 +465,6 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect {isAdminRole(userRole) && !collapsed && } - {/* Pinned "Open Chat" button at bottom */} -