From d5580b7a51373e00de726e9bd0b7b2254ed88760 Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Fri, 7 Aug 2026 19:44:50 -0700 Subject: [PATCH] feat(ui): manage admin-owned logging destinations Adds the destinations table, the scoped-destination add flow, the Edit scope dialog and the Not active badge to Logging and Alerts. Squashed onto the rebased disclosure branch; the previous history conflicted with staging's changes to the same dashboard files. --- ui/litellm-dashboard/eslint-suppressions.json | 15 +- .../hooks/credentials/useCredentials.ts | 4 +- .../LoggingCallbacksTable.test.tsx | 220 +++++++++++++++++- .../LoggingCallbacksTable.tsx | 30 ++- .../LoggingCallbacksTableColumns.tsx | 147 ++++++++++-- .../LoggingCallbacks/types.ts | 30 +++ .../src/components/callback_info_helpers.tsx | 11 +- .../AccessControlFields.tsx | 64 +++++ .../EditLoggingCredentialModal.test.tsx | 66 ++++++ .../EditLoggingCredentialModal.tsx | 79 +++++++ .../loggingCredentialApi.ts | 39 ++++ .../loggingDestinationFields.test.ts | 21 ++ .../loggingDestinationFields.ts | 138 +++++++++++ .../components/logging_settings_view.test.tsx | 22 ++ .../src/components/logging_settings_view.tsx | 30 +++ .../src/components/networking.test.ts | 48 ++++ .../src/components/networking.tsx | 18 +- .../organization/organization_view.tsx | 37 +++ .../src/components/settings.test.tsx | 183 ++++++++++++--- .../src/components/settings.tsx | 218 +++++++++++++++-- .../src/components/team/TeamInfo.test.tsx | 31 +++ .../src/components/team/TeamInfo.tsx | 10 + .../KeyInfoView.handleKeyUpdate.test.tsx | 10 + .../components/templates/key_edit_view.tsx | 1 + .../components/templates/key_info_view.tsx | 1 + ui/litellm-dashboard/src/utils/roles.ts | 5 + 26 files changed, 1378 insertions(+), 100 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/logging_credentials/AccessControlFields.tsx create mode 100644 ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.test.tsx create mode 100644 ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx create mode 100644 ui/litellm-dashboard/src/components/logging_credentials/loggingCredentialApi.ts create mode 100644 ui/litellm-dashboard/src/components/logging_credentials/loggingDestinationFields.test.ts create mode 100644 ui/litellm-dashboard/src/components/logging_credentials/loggingDestinationFields.ts diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index e5d17d49a33..a902e055c25 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -3135,6 +3135,16 @@ "count": 1 } }, + "src/components/logging_credentials/AccessControlFields.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/logging_credentials/EditLoggingCredentialModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/logging_settings_view.tsx": { "local/filename-pascal-case": { "count": 1 @@ -3555,6 +3565,9 @@ "local/no-complex-jsx-arrow": { "count": 4 }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 2 }, @@ -4310,4 +4323,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts index e3266de4fbc..17c3d44ebc1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts @@ -5,11 +5,11 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; const credentialsKeys = createQueryKeys("credentials"); -export const useCredentials = () => { +export const useCredentials = (enabled: boolean = true) => { const { accessToken } = useAuthorized(); return useQuery({ queryKey: credentialsKeys.list({}), queryFn: async () => await credentialListCall(accessToken!), - enabled: Boolean(accessToken), + enabled: enabled && Boolean(accessToken), }); }; diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx index 3ef7dee01b0..d2c6b1d35a9 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -97,12 +97,6 @@ describe("LoggingCallbacksTable", () => { expect(onDelete).toHaveBeenCalledWith(callback); }); - // Regression: `/get_callbacks` returns the same `name` twice when a - // callback is registered for both success and failure (e.g. `generic_api` - // → POST to spend-log on both 200 and 4xx/5xx). The UI used to ignore - // the `type` field and render every row as "Success", masking the - // failure registration. Reading `record.type` fixes the badge AND - // composing the row id with type avoids React's duplicate-key warning. it("renders distinct Success and Failure badges for same-name dual registration", () => { render( { expect(screen.getByText("Success")).toBeInTheDocument(); expect(screen.getByText("Failure")).toBeInTheDocument(); }); + + it("renders a global destination's scope", () => { + render( + , + ); + expect(screen.getByText("Global access")).toBeInTheDocument(); + expect(screen.queryByText("Success")).not.toBeInTheDocument(); + }); + + it("renders a scoped destination's resolved teams and orgs", () => { + render( + , + ); + expect(screen.getByText("team: t1")).toBeInTheDocument(); + expect(screen.getByText("team: t2")).toBeInTheDocument(); + expect(screen.getByText("org: o1")).toBeInTheDocument(); + }); + + it("a destination row edits access and deletes without exposing callback actions", async () => { + const user = userEvent.setup(); + const onEditAccess = vi.fn(); + const onDelete = vi.fn(); + const onTest = vi.fn(); + const callback = { + name: "dest", + variables: baseVars, + credentialName: "dest", + access: { global: true }, + resolvedScope: { global: true, teams: [], orgs: [] }, + }; + render( + , + ); + + await user.click(screen.getByTestId("callback-actions-dest-success")); + expect(screen.queryByTestId("callback-action-test")).not.toBeInTheDocument(); + expect(screen.queryByTestId("callback-action-edit")).not.toBeInTheDocument(); + await user.click(await screen.findByTestId("destination-action-edit-access")); + expect(onEditAccess).toHaveBeenCalledWith(callback); + + await user.click(screen.getByTestId("callback-actions-dest-success")); + await user.click(await screen.findByTestId("destination-action-delete")); + expect(onDelete).toHaveBeenCalledWith(callback); + expect(onTest).not.toHaveBeenCalled(); + }); + + it("a config callback row renders an empty scope", () => { + render( + , + ); + const row = screen.getByText("datadog").closest("tr"); + expect(row).not.toBeNull(); + expect(within(row as HTMLElement).getByText("—")).toBeInTheDocument(); + }); + + it("gives a destination and a config callback of the same name distinct row ids", () => { + const errors: string[] = []; + const spy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + + render( + , + ); + + expect(errors.filter((e) => /same key/i.test(e))).toHaveLength(0); + spy.mockRestore(); + }); +}); + +describe("read-only admin actions", () => { + // Regression: readOnly dropped the whole actions column, so an Admin Viewer lost Test + // on pre-existing callback rows even though that role is backend-authorized for + // /health/services. Only the mutating actions belong behind readOnly. + const row = { name: "langfuse", variables: { ...baseVars }, type: "success" as const }; + + it("keeps Test and hides the mutating actions for a read-only admin", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("callback-actions-langfuse-success")); + + expect(await screen.findByTestId("callback-action-test")).toBeInTheDocument(); + expect(screen.queryByTestId("callback-action-edit")).toBeNull(); + expect(screen.queryByTestId("callback-action-delete")).toBeNull(); + }); + + it("keeps every action for a full admin", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("callback-actions-langfuse-success")); + + expect(await screen.findByTestId("callback-action-test")).toBeInTheDocument(); + expect(screen.getByTestId("callback-action-edit")).toBeInTheDocument(); + expect(screen.getByTestId("callback-action-delete")).toBeInTheDocument(); + }); +}); + +describe("destination rows must not overstate what a destination does", () => { + const destination = (over: Record = {}) => ({ + name: "d1", + variables: baseVars, + credentialName: "d1", + destinationLabel: "Generic OTLP Collector", + resolvedScope: { global: true, teams: [], orgs: [] }, + ...over, + }); + + it("shows Not active, never a scope badge, when the backend cannot build the destination", () => { + // Regression: the cell read credential_info.access alone, so a destination the + // resolver excludes (no backend name, or values its adapter rejects) still rendered + // "Global access" and read as live. + render( + , + ); + expect(screen.getByText("Not active")).toBeInTheDocument(); + expect(screen.queryByText("Global access")).not.toBeInTheDocument(); + }); + + it("still shows the scope badge when the destination does build", () => { + render( + , + ); + expect(screen.getByText("Global access")).toBeInTheDocument(); + expect(screen.queryByText("Not active")).not.toBeInTheDocument(); + }); + + it("keeps the admin's own name for a destination named after a config callback", () => { + // Regression: the name column applied the callback registry's display label, so a + // destination the admin called "datadog" rendered as "Datadog", indistinguishable + // from the real Datadog callback row. + render( + , + ); + expect(screen.getByText("datadog")).toBeInTheDocument(); + expect(screen.queryByText("Datadog")).not.toBeInTheDocument(); + }); + + it("renders no actions trigger for a read-only admin, rather than one that opens empty", () => { + // Regression: readOnly suppressed both Edit scope and Delete, and destinations never + // get Test, so the trigger opened a menu with zero items and looked broken. + render(); + expect(screen.queryByTestId("callback-actions-d1-success")).not.toBeInTheDocument(); + }); + + it("still renders the actions trigger for a config callback a read-only admin can Test", async () => { + render( + , + ); + const trigger = screen.getByTestId("callback-actions-datadog-success_and_failure"); + await userEvent.click(trigger); + expect(await screen.findByTestId("callback-action-test")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx index d27b0cfd340..7f89bc81095 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx @@ -11,6 +11,7 @@ import { CallbackRow, callbackRowMode, getLoggingCallbacksTableColumns, + isDestination, } from "./LoggingCallbacksTableColumns"; import { AlertingObject } from "./types"; @@ -21,7 +22,9 @@ type LoggingCallbacksProps = { onTest?: (callback: AlertingObject) => void | Promise; onEdit?: (callback: AlertingObject) => void; onDelete?: (callback: AlertingObject) => void; + onEditAccess?: (callback: AlertingObject) => void; onAdd?: () => void; + readOnly?: boolean; }; function EmptyState() { @@ -45,26 +48,33 @@ export const LoggingCallbacksTable: React.FC = ({ onTest = () => {}, onEdit = () => {}, onDelete = () => {}, + onEditAccess = () => {}, onAdd = () => {}, + readOnly = false, }) => { const columns = useMemo(() => { - const deps = { availableCallbacks, onTest, onEdit, onDelete }; - return getLoggingCallbacksTableColumns(deps); - }, [availableCallbacks, onTest, onEdit, onDelete]); + // A read-only admin keeps the actions column so Test stays reachable -- that role is + // backend-authorized for /health/services. Dropping the column removed it entirely. + return getLoggingCallbacksTableColumns({ availableCallbacks, onTest, onEdit, onDelete, onEditAccess, readOnly }); + }, [availableCallbacks, onTest, onEdit, onDelete, onEditAccess, readOnly]); return (

Active Logging Callbacks

-
- -
+ {!readOnly && ( +
+ +
+ )} `${callback.name || index}-${callbackRowMode(callback)}`} + getRowId={(callback, index) => + `${isDestination(callback) ? "destination" : "callback"}:${callback.name || index}-${callbackRowMode(callback)}` + } isLoading={isLoading} loadingMessage="Loading callbacks…" noDataMessage={} diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx index 2263fe03b3d..6edcac9601c 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx @@ -4,6 +4,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { MoreHorizontal, Pencil, Play, Trash2 } from "lucide-react"; import { StatusBadge, type StatusTone } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { DropdownMenu, @@ -30,6 +31,8 @@ export type AvailableCallbacks = Record; export const callbackRowMode = (record: CallbackRow): string => record.type || record.mode || "success"; +export const isDestination = (record: AlertingObject): boolean => record.credentialName != null; + const CALLBACK_MODE_LABELS: Record = { success: "Success", failure: "Failure", @@ -42,14 +45,64 @@ function callbackModeTone(mode: string): StatusTone { return "info"; } +function ScopeCell({ callback }: { callback: AlertingObject }) { + const scope = callback.resolvedScope; + const hasResolvedScope = scope?.global === true || [...(scope?.teams ?? []), ...(scope?.orgs ?? [])].length > 0; + // A grant only routes traces if the credential also builds an exporter. Showing the + // grant alone advertised destinations the resolver excludes, so a misconfigured one + // read as live. + if (callback.resolvesToDestination === false) { + return ( + + Not active + + ); + } + if (!scope || !hasResolvedScope) { + return ; + } + if (scope.global) { + return Global access; + } + const items = [ + ...scope.teams.map((label) => ({ kind: "team" as const, label })), + ...scope.orgs.map((label) => ({ kind: "org" as const, label })), + ]; + const shown = items.slice(0, 4); + const remainder = items.length - shown.length; + return ( +
+ {shown.map((item) => ( + + {item.kind}: {item.label} + + ))} + {remainder > 0 && +{remainder} more} +
+ ); +} + interface CallbackRowActionsProps { callback: CallbackRow; + // A read-only admin keeps Test, which the backend authorizes for that role on + // /health/services, and loses everything that mutates. + readOnly?: boolean; onTest: (callback: AlertingObject) => void | Promise; onEdit: (callback: AlertingObject) => void; onDelete: (callback: AlertingObject) => void; + onEditAccess: (callback: AlertingObject) => void; } -function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowActionsProps) { +function CallbackRowActions({ callback, onTest, onEdit, onDelete, onEditAccess, readOnly }: CallbackRowActionsProps) { + const destination = isDestination(callback); + // Destinations get no Test, so a read-only admin has no action left on one. Rendering + // the trigger anyway opened an empty menu that looked broken rather than restricted. + if (destination && readOnly) { + return null; + } return ( - void onTest(callback)}> - - Test - - onEdit(callback)}> - - Edit - - - onDelete(callback)}> - - Delete - + {destination ? ( + !readOnly && ( + onEditAccess(callback)}> + + Edit scope + + ) + ) : ( + <> + void onTest(callback)}> + + Test + + {!readOnly && ( + onEdit(callback)}> + + Edit + + )} + + )} + {!readOnly && ( + <> + + onDelete(callback)} + > + + Delete + + + )} ); @@ -83,6 +157,8 @@ interface LoggingCallbacksTableColumnsDeps { onTest: (callback: AlertingObject) => void | Promise; onEdit: (callback: AlertingObject) => void; onDelete: (callback: AlertingObject) => void; + onEditAccess: (callback: AlertingObject) => void; + readOnly?: boolean; } export const getLoggingCallbacksTableColumns = ({ @@ -90,6 +166,8 @@ export const getLoggingCallbacksTableColumns = ({ onTest, onEdit, onDelete, + onEditAccess, + readOnly, }: LoggingCallbacksTableColumnsDeps): ColumnDef[] => [ { id: "name", @@ -99,11 +177,19 @@ export const getLoggingCallbacksTableColumns = ({ enableSorting: false, cell: ({ row }) => { const id = row.original.name; - const displayName = availableCallbacks[id]?.ui_callback_name || id; + // A destination keeps the name the admin gave it. Looking it up in the callback + // registry renamed a destination called "datadog" to "Datadog", making it + // indistinguishable in this column from the real Datadog callback row. + const displayName = isDestination(row.original) ? id : availableCallbacks[id]?.ui_callback_name || id; return ( - - {displayName} - +
+ + {displayName} + + {row.original.destinationLabel && ( + {row.original.destinationLabel} + )} +
); }, }, @@ -114,10 +200,26 @@ export const getLoggingCallbacksTableColumns = ({ size: 240, enableSorting: false, cell: ({ row }) => { + if (isDestination(row.original)) { + return ; + } const mode = callbackRowMode(row.original); return ; }, }, + { + id: "access", + meta: { title: "Scope", skeleton: "badge" }, + header: "Scope", + size: 280, + enableSorting: false, + cell: ({ row }) => + isDestination(row.original) ? ( + + ) : ( + + ), + }, { id: "actions", meta: { className: "text-right", headerClassName: "text-right" }, @@ -127,7 +229,14 @@ export const getLoggingCallbacksTableColumns = ({ enableHiding: false, cell: ({ row }) => (
- +
), }, diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts index 5d265f95484..d9720901ccb 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts @@ -8,6 +8,36 @@ export interface AlertingObject { // every row to render as "Success". type?: "success" | "failure" | "success_and_failure"; variables: AlertingVariables; + // Present only on rows backed by a logging credential (an OTEL trace + // destination). Config-callback rows leave these unset, which is how the table + // tells the two apart. + credentialName?: string; + destinationLabel?: string; + access?: CredentialAccess; + // The destination's whole stored credential_info. PATCH replaces + // credential_info wholesale, so an access edit has to resend all of it. + credentialInfo?: Record; + // The set of identities that route to this destination, resolved at render + // time from credential_info.access. Display labels only -- ids are not + // surfaced here. global=true bypasses the lists. + resolvedScope?: ResolvedScope; + // Whether the backend can actually build an exporter from this credential, decided + // there by the same function the request-time resolver and the team/org disclosure + // use. Read rather than recomputed: a second implementation of the adapter rules + // here would drift, and the drift is what let a dead destination look active. + resolvesToDestination?: boolean; +} + +export interface CredentialAccess { + global?: boolean; + teams?: string[]; + orgs?: string[]; +} + +export interface ResolvedScope { + global: boolean; + teams: string[]; + orgs: string[]; } export interface AlertingVariables { diff --git a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx index 7aa121dcca5..57b7da147b9 100644 --- a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx @@ -14,7 +14,7 @@ interface CallbackConfig { displayName: string; logo?: string; supports_key_team_logging: boolean; - dynamic_params: Record; + dynamic_params: Record; description: string; } @@ -112,6 +112,15 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [ }, description: "Langfuse v3 OTEL Logging Integration", }, + { + id: "weave_otel", + displayName: "Weave OTEL", + // OTEL v2 destination: admin-owned and routed by credential_info.access, + // not configured as a per-team callback here. + supports_key_team_logging: false, + dynamic_params: {}, + description: "Weave (W&B) OTEL Logging Integration", + }, { id: "langsmith", displayName: "LangSmith", diff --git a/ui/litellm-dashboard/src/components/logging_credentials/AccessControlFields.tsx b/ui/litellm-dashboard/src/components/logging_credentials/AccessControlFields.tsx new file mode 100644 index 00000000000..9c7eab205eb --- /dev/null +++ b/ui/litellm-dashboard/src/components/logging_credentials/AccessControlFields.tsx @@ -0,0 +1,64 @@ +import { Form, Select, Switch } from "antd"; +import React from "react"; + +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types"; + +interface AccessControlFieldsProps { + // value/onChange are optional so the component can be driven either directly + // (the Add modal) or injected by an antd Form.Item (the Edit modal). + value?: CredentialAccess; + onChange?: (next: CredentialAccess) => void; +} + +// Admin-owned access for a logging destination: global (every request) or a set of +// teams/orgs. Per-key targeting is intentionally absent here -- it lives on the key's +// own page, since a key's token rotates on regenerate while team/org ids are stable. +const AccessControlFields: React.FC = ({ value = {}, onChange = () => {} }) => { + const { data: teams } = useTeams(); + const { data: orgs } = useOrganizations(); + const isGlobal = value.global === true; + + const teamOptions = (teams ?? []).map((t) => ({ value: t.team_id, label: t.team_alias || t.team_id })); + const orgOptions = (orgs ?? []).map((o) => ({ + value: o.organization_id, + label: o.organization_alias || o.organization_id, + })); + + return ( + <> + + onChange({ ...value, global })} /> + + + onChange({ ...value, orgs: orgIds })} + options={orgOptions} + optionFilterProp="label" + style={{ width: "100%" }} + /> + + + ); +}; + +export default AccessControlFields; diff --git a/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.test.tsx b/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.test.tsx new file mode 100644 index 00000000000..e8231776cda --- /dev/null +++ b/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { credentialUpdateCall } from "../networking"; +import EditLoggingCredentialModal from "./EditLoggingCredentialModal"; + +vi.mock("../networking", () => ({ + credentialUpdateCall: vi.fn(), +})); + +vi.mock("../molecules/notifications_manager", () => ({ + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +// The access picker fetches teams/orgs through react-query; this test is about the +// PATCH body the modal builds, so the picker is stubbed out. +vi.mock("./AccessControlFields", () => ({ default: () => null })); + +describe("EditLoggingCredentialModal", () => { + const mockUpdate = vi.mocked(credentialUpdateCall); + + beforeEach(() => { + vi.clearAllMocks(); + mockUpdate.mockResolvedValue({} as never); + }); + + // Regression: PATCH /credentials replaces credential_info wholesale. Sending only + // { access } dropped credential_type/description, which stops the row being a logging + // destination at all -- it vanishes from resolved_logging_exporters and exports nothing. + it("resends the destination's whole credential_info with only access swapped", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByRole("button", { name: /save/i })); + + await waitFor(() => expect(mockUpdate).toHaveBeenCalled()); + const [, name, body] = mockUpdate.mock.calls[0]; + expect(name).toBe("dest-1"); + expect(body.credential_name).toBe("dest-1"); + // secrets are masked on read, so an access edit must not resend them + expect(body.credential_values).toEqual({}); + // every sibling field survives the wholesale replace + expect(body.credential_info).toMatchObject({ + credential_type: "logging", + description: "langfuse_otel", + host: "https://collector.internal", + }); + expect(body.credential_info.access).toEqual({ global: false, teams: ["team-a"], orgs: [] }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx b/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx new file mode 100644 index 00000000000..30f6a910af7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/logging_credentials/EditLoggingCredentialModal.tsx @@ -0,0 +1,79 @@ +import { Form, Modal } from "antd"; +import React from "react"; + +import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types"; +import NotificationsManager from "../molecules/notifications_manager"; +import { credentialUpdateCall } from "../networking"; +import AccessControlFields from "./AccessControlFields"; + +interface EditLoggingCredentialModalProps { + accessToken: string; + credentialName: string | null; + access?: CredentialAccess; + // The destination's stored credential_info. PATCH replaces credential_info + // wholesale, so the whole object is resent with only access swapped; sending + // access alone would drop credential_type/description and stop the row being + // a logging destination at all. + credentialInfo?: Record; + open: boolean; + onClose: () => void; + onSaved: () => void; +} + +interface AccessForm { + access?: CredentialAccess; +} + +const EditLoggingCredentialModal: React.FC = ({ + accessToken, + credentialName, + access, + credentialInfo, + open, + onClose, + onSaved, +}) => { + // destroyOnClose remounts the Form each open, so initialValues re-seeds from the + // current destination -- no effect syncing prop into state. + const [form] = Form.useForm(); + + const handleSave = async () => { + if (!credentialName) return; + const current = form.getFieldsValue().access ?? {}; + // Always send the full access object; a global grant supersedes team/org. + const next: CredentialAccess = current.global + ? { global: true, teams: [], orgs: [] } + : { global: false, teams: current.teams ?? [], orgs: current.orgs ?? [] }; + try { + await credentialUpdateCall(accessToken, credentialName, { + credential_name: credentialName, + credential_values: {}, + credential_info: { ...(credentialInfo ?? {}), access: next }, + }); + NotificationsManager.success("Access updated"); + onSaved(); + onClose(); + } catch (error) { + NotificationsManager.fromBackend(error instanceof Error ? error.message : String(error)); + } + }; + + return ( + + form={form} layout="vertical" preserve={false} initialValues={{ access: access ?? {} }}> + + + + + + ); +}; + +export default EditLoggingCredentialModal; diff --git a/ui/litellm-dashboard/src/components/logging_credentials/loggingCredentialApi.ts b/ui/litellm-dashboard/src/components/logging_credentials/loggingCredentialApi.ts new file mode 100644 index 00000000000..639eb88aa10 --- /dev/null +++ b/ui/litellm-dashboard/src/components/logging_credentials/loggingCredentialApi.ts @@ -0,0 +1,39 @@ +import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types"; +import { credentialCreateCall } from "../networking"; +import { LOGGING_DESTINATION_BACKENDS } from "./loggingDestinationFields"; + +// The OTEL trace-destination backend ids, managed as admin-owned logging destinations +// (credentials). The `langfuse` v2 SDK logger and the generic `otel` callback are +// deliberately not here; they keep their existing global-callback behavior untouched. +export const LOGGING_BACKEND_IDS: ReadonlySet = new Set(LOGGING_DESTINATION_BACKENDS.map((b) => b.id)); + +// These backends are reachable two ways from the Add modal, so the dropdown carries a +// prefixed option id for the destination branch. Without it a bare `arize` would be +// ambiguous, and picking it would silently create an inert credential instead of the +// proxy-wide callback the same label used to produce. +export const DESTINATION_OPTION_PREFIX = "destination:"; + +export const backendLabel = (id?: string): string => + LOGGING_DESTINATION_BACKENDS.find((b) => b.id === id)?.label ?? id ?? "-"; + +export interface CreateLoggingCredentialInput { + credentialName: string; + backend: string; + values: Record; + host?: string; + access?: CredentialAccess; +} + +// One place that owns the logging-credential contract: the credential_type tag, the +// backend in description, the non-secret host, and the admin-owned access grant. +export const createLoggingCredential = async (accessToken: string, input: CreateLoggingCredentialInput) => + credentialCreateCall(accessToken, { + credential_name: input.credentialName, + credential_values: input.values, + credential_info: { + credential_type: "logging", + description: input.backend, + ...(input.host ? { host: input.host } : {}), + ...(input.access ? { access: input.access } : {}), + }, + }); diff --git a/ui/litellm-dashboard/src/components/logging_credentials/loggingDestinationFields.test.ts b/ui/litellm-dashboard/src/components/logging_credentials/loggingDestinationFields.test.ts new file mode 100644 index 00000000000..8a59ce7f9df --- /dev/null +++ b/ui/litellm-dashboard/src/components/logging_credentials/loggingDestinationFields.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { LOGGING_DESTINATION_BACKENDS } from "./loggingDestinationFields"; + +// Arize routes a trace to a project via the model_id / arize.project.name span +// resource attribute, and its OTLP ingestion rejects any span that lacks it +// ("model_id span resource attribute or arize.project.name span attribute is +// required"). The backend reads that project from the credential's +// arize_project_name value, so the create form must collect it as a required +// field; without it every Arize destination created in the UI silently drops +// 100% of its traces. +describe("Arize logging destination fields", () => { + const arize = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === "arize"); + + it("exposes a required arize_project_name field", () => { + expect(arize).toBeDefined(); + const projectField = arize!.fields.find((f) => f.name === "arize_project_name"); + expect(projectField).toBeDefined(); + expect(projectField!.optional).not.toBe(true); + }); +}); diff --git a/ui/litellm-dashboard/src/components/logging_credentials/loggingDestinationFields.ts b/ui/litellm-dashboard/src/components/logging_credentials/loggingDestinationFields.ts new file mode 100644 index 00000000000..097d23ffbbf --- /dev/null +++ b/ui/litellm-dashboard/src/components/logging_credentials/loggingDestinationFields.ts @@ -0,0 +1,138 @@ +// Create-time field shapes for an admin-owned logging destination, keyed by the +// OTEL v2 backend it binds to. This is the inverse of the per-team picker: the +// picker selects a destination by name; these fields are what an admin types when +// CREATING the named destination in the registry. Keeping the raw keys here (the +// admin registry) and out of the per-team form is the provider/logging separation. + +export type LoggingFieldType = "text" | "password"; + +export interface LoggingField { + name: string; + label: string; + type: LoggingFieldType; + optional?: boolean; + // Example value shown as the input placeholder, so an admin knows the format. + placeholder?: string; +} + +export interface LoggingDestinationBackend { + id: string; // the callback_name the credential is bound under + label: string; + fields: LoggingField[]; + // The non-secret field that names the destination host/endpoint. Surfaced in the + // list so an admin can tell e.g. an EU from a US destination apart. + hostField: string; +} + +export const LOGGING_DESTINATION_BACKENDS: LoggingDestinationBackend[] = [ + { + id: "langfuse_otel", + label: "Langfuse OTEL", + fields: [ + { + name: "langfuse_host", + label: "Langfuse Host", + type: "text", + placeholder: "https://cloud.langfuse.com", + }, + { + name: "langfuse_public_key", + label: "Public Key", + type: "password", + placeholder: "pk-lf-00000000-0000-0000-0000-000000000000", + }, + { + name: "langfuse_secret_key", + label: "Secret Key", + type: "password", + placeholder: "sk-lf-00000000-0000-0000-0000-000000000000", + }, + ], + hostField: "langfuse_host", + }, + { + id: "arize", + label: "Arize", + fields: [ + { + name: "arize_space_id", + label: "Space ID", + type: "password", + placeholder: "U3BhY2U6MTIzNDU6YWJjZA==", + }, + { + name: "arize_api_key", + label: "API Key", + type: "password", + placeholder: "ak-0000aaaa-1111-2222-3333-444455556666", + }, + { + name: "arize_project_name", + label: "Project Name", + type: "text", + placeholder: "my-llm-app", + }, + { + name: "arize_endpoint", + label: "Endpoint (gRPC)", + type: "text", + optional: true, + placeholder: "https://otlp.arize.com/v1", + }, + { + name: "arize_http_endpoint", + label: "Endpoint (HTTP)", + type: "text", + optional: true, + placeholder: "http://collector.internal/v1/traces", + }, + ], + hostField: "arize_endpoint", + }, + { + id: "weave_otel", + label: "Weave", + fields: [ + { + name: "wandb_api_key", + label: "W&B API Key", + type: "password", + placeholder: "0123456789abcdef0123456789abcdef01234567", + }, + { + name: "weave_project_id", + label: "Project (entity/project)", + type: "text", + placeholder: "my-team/my-project", + }, + { + name: "weave_endpoint", + label: "Endpoint", + type: "text", + optional: true, + placeholder: "https://trace.wandb.ai", + }, + ], + hostField: "weave_endpoint", + }, + { + id: "generic", + label: "Generic OTLP Collector", + fields: [ + { + name: "otel_endpoint", + label: "OTLP Endpoint", + type: "text", + placeholder: "https://collector.example.com:4318/v1/traces", + }, + { + name: "otel_headers", + label: "Headers (k=v,k2=v2)", + type: "text", + optional: true, + placeholder: "x-api-key=abc123,x-team=42", + }, + ], + hostField: "otel_endpoint", + }, +]; diff --git a/ui/litellm-dashboard/src/components/logging_settings_view.test.tsx b/ui/litellm-dashboard/src/components/logging_settings_view.test.tsx index b0a1b54bac7..9f62ad01bd7 100644 --- a/ui/litellm-dashboard/src/components/logging_settings_view.test.tsx +++ b/ui/litellm-dashboard/src/components/logging_settings_view.test.tsx @@ -38,3 +38,25 @@ describe("LoggingSettingsView logos", () => { expect(screen.getByText("C")).toBeInTheDocument(); }); }); + +describe("LoggingSettingsView scoped exporters", () => { + it("says nothing about exporters when the caller has not resolved them", () => { + render(); + + expect(screen.queryByText("No logging exporters assigned")).not.toBeInTheDocument(); + expect(screen.queryByText("Logging Exporters")).not.toBeInTheDocument(); + }); + + it("reports none only when the caller resolved an empty list", () => { + render(); + + expect(screen.getByText("No logging exporters assigned")).toBeInTheDocument(); + }); + + it("lists the exporters the caller resolved", () => { + render(); + + expect(screen.getByText("langfuse-eu")).toBeInTheDocument(); + expect(screen.queryByText("No logging exporters assigned")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/logging_settings_view.tsx b/ui/litellm-dashboard/src/components/logging_settings_view.tsx index 97eca9d6247..17631cfa76f 100644 --- a/ui/litellm-dashboard/src/components/logging_settings_view.tsx +++ b/ui/litellm-dashboard/src/components/logging_settings_view.tsx @@ -13,6 +13,11 @@ interface LoggingConfig { interface LoggingSettingsViewProps { loggingConfigs?: LoggingConfig[]; disabledCallbacks?: string[]; + // Destinations that route to this identity via credential_info.access + // (teams/orgs/global), resolved server-side. Display only. Left undefined by a + // caller that has not resolved them, which hides the section rather than + // asserting there are none. + scopedExporters?: string[]; variant?: "card" | "inline"; className?: string; } @@ -20,6 +25,7 @@ interface LoggingSettingsViewProps { export function LoggingSettingsView({ loggingConfigs = [], disabledCallbacks = [], + scopedExporters, variant = "card", className = "", }: LoggingSettingsViewProps) { @@ -57,6 +63,30 @@ export function LoggingSettingsView({ const content = (
+ {scopedExporters !== undefined && ( +
+
+ + Logging Exporters + {scopedExporters.length} +
+ {scopedExporters.length > 0 ? ( +
+ {scopedExporters.map((name, index) => ( + + {name} + + ))} +
+ ) : ( +
+ + No logging exporters assigned +
+ )} +
+ )} + {/* Logging Integrations Section */}
diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index d32586e1774..2e3d0687712 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -636,3 +636,51 @@ describe("getAutoRouterClassifierDefaultPromptCall", () => { expect(String(mockFetch.mock.calls[1][0])).not.toContain("tier_labels"); }); }); + +describe("credential path encoding", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + const mockOk = () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: vi.fn().mockResolvedValue("{}"), + } as any); + global.fetch = mockFetch as any; + return mockFetch; + }; + + const requestedUrl = (mockFetch: ReturnType): URL => { + const [url] = mockFetch.mock.calls[0]; + const urlStr = typeof url === "string" ? url : (url as Request).url; + return new URL(urlStr, "http://example.com"); + }; + + it("encodes the credential name on delete so the request cannot address a different credential", async () => { + const mockFetch = mockOk(); + + await Networking.credentialDeleteCall("token", "dest/other?force=1"); + + const parsed = requestedUrl(mockFetch); + expect(parsed.pathname.endsWith("/credentials/dest%2Fother%3Fforce%3D1")).toBe(true); + expect(parsed.search).toBe(""); + }); + + it("encodes the credential name on update so the patch reaches the named credential", async () => { + const mockFetch = mockOk(); + + await Networking.credentialUpdateCall("token", "dest/other?force=1", { credential_info: {} }); + + const parsed = requestedUrl(mockFetch); + expect(parsed.pathname.endsWith("/credentials/dest%2Fother%3Fforce%3D1")).toBe(true); + expect(parsed.search).toBe(""); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 321dfd6b2a6..14a93d5a0c2 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -245,6 +245,7 @@ export interface Organization { users: any[] | null; members: any[] | null; object_permission?: ObjectPermission | null; + resolved_logging_exporters?: string[] | null; } export interface CredentialItem { @@ -254,6 +255,19 @@ export interface CredentialItem { custom_llm_provider?: string; description?: string; required?: boolean; + // "logging" tags an admin-owned trace destination (Option A: lives in the + // free-form credential_info, no schema migration). Absent = a provider credential. + credential_type?: string; + // Non-secret destination host/endpoint, surfaced in the logging credentials list. + host?: string; + // Admin-owned access grant for a logging destination: who may see/assign it. + // global reaches everyone; teams/orgs list ids. Visibility only -- on its own it + // never enables tracing for a request. + access?: { + global?: boolean; + teams?: string[]; + orgs?: string[]; + }; }; } @@ -2627,7 +2641,7 @@ export const credentialGetCall = async (accessToken: string, credentialName: str export const credentialDeleteCall = async (accessToken: string, credentialName: string) => { try { - const data = await apiClient.delete(`/credentials/${credentialName}`, { accessToken }); + const data = await apiClient.delete(`/credentials/${encodeURIComponent(credentialName)}`, { accessToken }); return data; // Handle success - you might want to update some state or UI based on the created key } catch (error) { @@ -2651,7 +2665,7 @@ export const credentialUpdateCall = async ( } } - const data = await apiClient.patch(`/credentials/${credentialName}`, { + const data = await apiClient.patch(`/credentials/${encodeURIComponent(credentialName)}`, { accessToken, body: { ...formValues, // Include formValues in the request body diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index a9f79810e1d..6a68cd8242a 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useVisitedTabs } from "@/hooks/useVisitedTabs"; import { MoneyCell } from "@/components/shared/table_cells"; import CopyButton from "@/components/shared/CopyButton"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -58,6 +59,11 @@ const OrganizationInfoView: React.FC = ({ const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); + const loggingExporterBadges = useMemo( + () => (orgData?.resolved_logging_exporters ?? []).map((name) => ({ name })), + [orgData], + ); + const handleMemberAdd = async (values: any) => { try { if (accessToken == null) { @@ -242,6 +248,23 @@ const OrganizationInfoView: React.FC = ({ + + +

Logging Exporters

+
+ {loggingExporterBadges.length > 0 ? ( + loggingExporterBadges.map((exporter, index) => ( + + {exporter.name} + + )) + ) : ( + None + )} +
+
+
+ = ({
Reset: {orgData.litellm_budget_table.budget_duration || "Never"}
+
+

Logging Exporters

+ {loggingExporterBadges.length > 0 ? ( +
+ {loggingExporterBadges.map((exporter, index) => ( + + {exporter.name} + + ))} +
+ ) : ( +
None
+ )} +
+ render( + + + , + ); + vi.mock("./networking", () => ({ getCallbacksCall: vi.fn(), getCallbackConfigsCall: vi.fn(), @@ -40,6 +58,12 @@ vi.mock("./CloudZeroCostTracking/CloudZeroCostTracking", () => ({ default: () =>
Mock CloudZero Cost Tracking
, })); +let credentialsFixture: { credentials: unknown[] } = { credentials: [] }; + +vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({ + useCredentials: () => ({ data: credentialsFixture, refetch: vi.fn() }), +})); + // Polyfill ResizeObserver for components relying on it in tests if (typeof window !== "undefined" && !window.ResizeObserver) { window.ResizeObserver = class ResizeObserver { @@ -68,7 +92,7 @@ beforeAll(() => { describe("Settings", () => { const defaultProps = { accessToken: "token", - userRole: "admin", + userRole: "Admin", userID: "user-123", premiumUser: false, }; @@ -78,6 +102,7 @@ describe("Settings", () => { beforeEach(() => { vi.clearAllMocks(); + credentialsFixture = { credentials: [] }; mockGetCallbacksCall.mockResolvedValue({ callbacks: [], available_callbacks: [], @@ -88,7 +113,7 @@ describe("Settings", () => { }); it("should render the logging callbacks tab when access token is provided", async () => { - const { getByText } = render(); + const { getByText } = renderSettings(defaultProps); await waitFor(() => { expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); @@ -96,7 +121,7 @@ describe("Settings", () => { }); it("should display additional settings tabs", async () => { - const { getByText } = render(); + const { getByText } = renderSettings(defaultProps); await waitFor(() => { expect(getByText("CloudZero Cost Tracking")).toBeInTheDocument(); @@ -107,7 +132,7 @@ describe("Settings", () => { }); it("should load callback configs from the backend when access token is provided", async () => { - render(); + renderSettings(defaultProps); await waitFor(() => { expect(mockGetCallbackConfigsCall).toHaveBeenCalledWith(defaultProps.accessToken); @@ -115,35 +140,30 @@ describe("Settings", () => { }); it("should display edit modal with fields when edit is clicked", async () => { + // Datadog is a plain config callback that renders a row with the legacy + // Test/Edit/Delete actions. Config-owned OTEL callbacks (arize, langfuse_otel, + // etc.) also render as their own rows now; see the regression test below. const mockCallback = { - name: "langfuse", + name: "datadog", variables: { - LANGFUSE_PUBLIC_KEY: "test-public-key", - LANGFUSE_SECRET_KEY: "test-secret-key", - LANGFUSE_HOST: "https://test.langfuse.com", - SLACK_WEBHOOK_URL: null, - OPENMETER_API_KEY: null, + DD_API_KEY: "test-api-key", + DD_SITE: "us5.datadoghq.com", }, }; const mockCallbackConfig = { - id: "langfuse", - displayName: "Langfuse", + id: "datadog", + displayName: "Datadog", dynamic_params: { - LANGFUSE_PUBLIC_KEY: { - type: "text", - ui_name: "Public Key", - required: true, - }, - LANGFUSE_SECRET_KEY: { + DD_API_KEY: { type: "password", - ui_name: "Secret Key", + ui_name: "API Key", required: true, }, - LANGFUSE_HOST: { + DD_SITE: { type: "text", - ui_name: "Host", - required: false, + ui_name: "Site", + required: true, }, }, }; @@ -151,10 +171,10 @@ describe("Settings", () => { mockGetCallbacksCall.mockResolvedValue({ callbacks: [mockCallback], available_callbacks: { - langfuse: { - litellm_callback_name: "langfuse", - litellm_callback_params: ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST"], - ui_callback_name: "Langfuse", + datadog: { + litellm_callback_name: "datadog", + litellm_callback_params: ["DD_API_KEY", "DD_SITE"], + ui_callback_name: "Datadog", }, }, alerts: [], @@ -163,17 +183,17 @@ describe("Settings", () => { mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]); const user = userEvent.setup(); - const { getByText } = render(); + const { getByText } = renderSettings(defaultProps); await waitFor(() => { expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText("Langfuse")).toBeInTheDocument(); + expect(getByText("Datadog")).toBeInTheDocument(); }); - await user.click(screen.getByTestId("callback-actions-langfuse-success")); + await user.click(screen.getByTestId("callback-actions-datadog-success")); await user.click(await screen.findByTestId("callback-action-edit")); await waitFor(() => { @@ -181,12 +201,63 @@ describe("Settings", () => { }); await waitFor(() => { - expect(getByText("Public Key")).toBeInTheDocument(); - expect(getByText("Secret Key")).toBeInTheDocument(); - expect(getByText("Host")).toBeInTheDocument(); + expect(getByText("API Key")).toBeInTheDocument(); + expect(getByText("Site")).toBeInTheDocument(); }); }); + it("should render a config-owned OTEL callback (langfuse_otel) as its own row", async () => { + // Regression: a proxy-wide langfuse_otel/arize/weave/generic callback configured + // via /config/update must stay visible and manageable in the table. It was being + // filtered out by backend id, removing config-owned rows (not just duplicates). + mockGetCallbacksCall.mockResolvedValue({ + callbacks: [{ name: "langfuse_otel", variables: { LANGFUSE_PUBLIC_KEY: "pk", LANGFUSE_SECRET_KEY: "sk" } }], + available_callbacks: { + langfuse_otel: { + litellm_callback_name: "langfuse_otel", + litellm_callback_params: ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"], + ui_callback_name: "Langfuse OTEL", + }, + }, + alerts: [], + }); + mockGetCallbackConfigsCall.mockResolvedValue([ + { id: "langfuse_otel", displayName: "Langfuse OTEL", dynamic_params: {} }, + ]); + + const { getByText } = renderSettings(defaultProps); + + await waitFor(() => { + expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); + }); + await waitFor(() => { + expect(getByText("Langfuse OTEL")).toBeInTheDocument(); + }); + }); + + it("should keep rendering every destination when one stored access scope is not a list", async () => { + credentialsFixture = { + credentials: [ + { + credential_name: "legacy-shape", + credential_info: { credential_type: "logging", description: "langfuse_otel", access: { teams: "team-1" } }, + }, + { + credential_name: "well-formed", + credential_info: { credential_type: "logging", description: "langfuse_otel", access: { teams: ["team-2"] } }, + }, + ], + }; + + const { getByText } = renderSettings(defaultProps); + + await waitFor(() => { + expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); + }); + expect(getByText("legacy-shape")).toBeInTheDocument(); + expect(getByText("well-formed")).toBeInTheDocument(); + }); + it("should hold the callbacks table in loading state until the fetch settles", async () => { let resolveCallbacks: (value: { callbacks: never[]; @@ -199,7 +270,7 @@ describe("Settings", () => { }), ); - render(); + renderSettings(defaultProps); expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); @@ -214,7 +285,7 @@ describe("Settings", () => { }); it("should resolve loading without fetching when the user id is missing", async () => { - render(); + renderSettings({ ...defaultProps, userID: null }); await waitFor(() => { expect(screen.queryByTestId("skeleton-row")).not.toBeInTheDocument(); @@ -224,7 +295,7 @@ describe("Settings", () => { }); it("should display CloudZero Cost Tracking tab", async () => { - const { getByText } = render(); + const { getByText } = renderSettings(defaultProps); await waitFor(() => { expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); @@ -274,3 +345,45 @@ describe("CallbackSelector logos", () => { expect(screen.getByText("N")).toBeInTheDocument(); }); }); + +describe("Add Callback dropdown", () => { + // Regression: the four OTEL backend ids were filtered out of the config-owned + // callback list and re-added as destinations under the SAME ids, so picking "Arize" + // silently switched from creating a proxy-wide callback (/config/update) to creating + // a by-default-inert logging credential (/credentials). Both paths must be offered, + // and distinguishable, so the pre-existing flow still exists. + const defaultProps = { + accessToken: "token", + userRole: "Admin", + userID: "user-123", + premiumUser: false, + }; + + it("offers the config-owned OTEL callback and the scoped destination as separate options", async () => { + vi.clearAllMocks(); + vi.mocked(alertingSettingsCall).mockResolvedValue([]); + vi.mocked(getCallbacksCall).mockResolvedValue({ + callbacks: [], + available_callbacks: { + arize: { + litellm_callback_name: "arize", + litellm_callback_params: ["ARIZE_SPACE_ID", "ARIZE_API_KEY"], + ui_callback_name: "Arize", + }, + }, + alerts: [], + }); + vi.mocked(getCallbackConfigsCall).mockResolvedValue([{ id: "arize", displayName: "Arize", dynamic_params: {} }]); + + const { getByText } = renderSettings(defaultProps); + await waitFor(() => { + expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); + }); + + fireEvent.click(getByText("Add Callback")); + fireEvent.mouseDown(await screen.findByRole("combobox")); + + expect(await screen.findByText("Arize")).toBeInTheDocument(); + expect(screen.getByText("Arize (scoped destination)")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 904fd4d611e..9def17ede04 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -30,6 +30,7 @@ import AlertingSettings from "./alerting/alerting_settings"; import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; import { + credentialDeleteCall, deleteCallback, getCallbackConfigsCall, getCallbacksCall, @@ -37,7 +38,19 @@ import { setCallbacksCall, } from "./networking"; import { LoggingCallbacksTable } from "./Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable"; -import { AlertingObject } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types"; +import { AlertingObject, CredentialAccess, ResolvedScope } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types"; +import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; +import { canReadCredentialsRole, isProxyAdminRole } from "@/utils/roles"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import EditLoggingCredentialModal from "./logging_credentials/EditLoggingCredentialModal"; +import AccessControlFields from "./logging_credentials/AccessControlFields"; +import { + backendLabel, + createLoggingCredential, + DESTINATION_OPTION_PREFIX, +} from "./logging_credentials/loggingCredentialApi"; +import { LOGGING_DESTINATION_BACKENDS } from "./logging_credentials/loggingDestinationFields"; import { parseErrorMessage } from "./shared/errorUtils"; interface SettingsPageProps { accessToken: string | null; @@ -236,6 +249,61 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const [isAddingCallback, setIsAddingCallback] = useState(false); const [isDeletingCallback, setIsDeletingCallback] = useState(false); + // OTEL trace destinations are proxy-admin-managed credentials tagged + // credential_type=logging; they share the one Active Logging Callbacks table as + // rows alongside config callbacks. Only a proxy admin (or admin-viewer, read-only) + // may read them, so non-admins skip the fetch entirely. + const isProxyAdmin = userRole != null && isProxyAdminRole(userRole); + const { data: credentialData, refetch: refetchCredentials } = useCredentials(canReadCredentialsRole(userRole)); + const { data: teamsData } = useTeams(); + const { data: orgsData } = useOrganizations(); + const [editAccessFor, setEditAccessFor] = useState<{ + name: string; + access?: CredentialAccess; + credentialInfo?: Record; + } | null>(null); + // access for the destination branch of the unified Add modal + const [addAccess, setAddAccess] = useState({}); + const addingDestination = selectedCallback != null && selectedCallback.startsWith(DESTINATION_OPTION_PREFIX); + const selectedDestinationBackend = addingDestination + ? selectedCallback.slice(DESTINATION_OPTION_PREFIX.length) + : null; + const addingDestinationFields = + LOGGING_DESTINATION_BACKENDS.find((b) => b.id === selectedDestinationBackend)?.fields ?? []; + + const teamAlias = (id: string): string => { + const t = (teamsData ?? []).find((team) => team.team_id === id); + return t?.team_alias || id; + }; + const orgAlias = (id: string): string => { + const o = (orgsData ?? []).find((org) => org.organization_id === id); + return o?.organization_alias || id; + }; + + const asIdList = (value: unknown): string[] => + Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; + + const resolveScope = (access?: CredentialAccess): ResolvedScope => ({ + global: access?.global === true, + teams: asIdList(access?.teams).map(teamAlias), + orgs: asIdList(access?.orgs).map(orgAlias), + }); + + const destinationRows: AlertingObject[] = (credentialData?.credentials ?? []) + .filter((c) => c.credential_info?.credential_type === "logging") + .map((c) => ({ + name: c.credential_name, + variables: {} as AlertingObject["variables"], + credentialName: c.credential_name, + destinationLabel: c.credential_info?.host + ? `${backendLabel(c.credential_info?.description)} · ${c.credential_info.host}` + : backendLabel(c.credential_info?.description), + access: c.credential_info?.access, + credentialInfo: c.credential_info as Record | undefined, + resolvedScope: resolveScope(c.credential_info?.access), + resolvesToDestination: (c as { resolves_to_destination?: boolean }).resolves_to_destination, + })); + useEffect(() => { if (!accessToken) { return; @@ -374,6 +442,34 @@ const Settings: React.FC = ({ accessToken, userRole, userID, if (!new_callback) { return; } + if (new_callback.startsWith(DESTINATION_OPTION_PREFIX) && accessToken) { + const backendId = new_callback.slice(DESTINATION_OPTION_PREFIX.length); + const backendDef = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === backendId); + const fields = backendDef?.fields ?? []; + const values = Object.fromEntries( + fields.filter((f) => formValues[f.name]).map((f) => [f.name, formValues[f.name]]), + ); + const host = backendDef ? formValues[backendDef.hostField] : undefined; + const hasAccess = addAccess.global || addAccess.teams?.length || addAccess.orgs?.length; + try { + await createLoggingCredential(accessToken, { + credentialName: formValues.credential_name, + backend: backendId, + values, + host, + access: hasAccess ? addAccess : undefined, + }); + NotificationsManager.success("Logging destination created"); + refetchCredentials(); + setShowAddCallbacksModal(false); + setSelectedCallback(null); + setAddAccess({}); + addForm.resetFields(); + } catch (error) { + NotificationsManager.fromBackend(parseErrorMessage(error)); + } + return; + } await handleCallbackSubmit(formValues, new_callback, false); }; @@ -422,13 +518,22 @@ const Settings: React.FC = ({ accessToken, userRole, userID, try { setIsDeletingCallback(true); - await deleteCallback(accessToken, callbackToDelete.name); - NotificationsManager.success(`Callback ${callbackToDelete.name} deleted successfully`); - - // Refresh the callbacks list - if (userID && userRole) { - const data = await getCallbacksCall(accessToken, userID, userRole); - setCallbacks(data.callbacks); + // A destination row carries a credentialName; it is a logging credential and is + // deleted (with its stored collector secrets) through the credential endpoint. A + // plain config callback is deleted through the callback endpoint. Both run only + // after the same delete confirmation, so a mis-click can't drop either instantly. + if (callbackToDelete.credentialName) { + await credentialDeleteCall(accessToken, callbackToDelete.credentialName); + NotificationsManager.success("Logging destination deleted"); + refetchCredentials(); + } else { + await deleteCallback(accessToken, callbackToDelete.name); + NotificationsManager.success(`Callback ${callbackToDelete.name} deleted successfully`); + // Refresh the callbacks list + if (userID && userRole) { + const data = await getCallbacksCall(accessToken, userID, userRole); + setCallbacks(data.callbacks); + } } setShowDeleteConfirmModal(false); @@ -459,14 +564,19 @@ const Settings: React.FC = ({ accessToken, userRole, userID, setShowAddCallbacksModal(true)} onEdit={(cb) => { setSelectedEditCallback(cb); setShowEditCallback(true); }} + onEditAccess={(cb) => + cb.credentialName && + setEditAccessFor({ name: cb.credentialName, access: cb.access, credentialInfo: cb.credentialInfo }) + } onDelete={(cb) => handleDeleteCallback(cb)} onTest={async (cb) => { try { @@ -477,6 +587,17 @@ const Settings: React.FC = ({ accessToken, userRole, userID, } }} /> + {accessToken && ( + setEditAccessFor(null)} + onSaved={() => refetchCredentials()} + /> + )}
@@ -585,6 +706,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, setShowAddCallbacksModal(false); setSelectedCallback(null); setSelectedCallbackParams([]); + setAddAccess({}); }} footer={null} > @@ -606,16 +728,52 @@ const Settings: React.FC = ({ accessToken, userRole, userID, labelAlign="left" > ({ + id: `${DESTINATION_OPTION_PREFIX}${b.id}`, + displayName: `${b.label} (scoped destination)`, + logo: "", + })), + ]} selectedCallback={selectedCallback} onCallbackChange={handleSelectedCallbackChange} /> - + {addingDestination ? ( +
+ Name} + name="credential_name" + rules={[{ required: true, message: "Please enter a name" }]} + > + + + {addingDestinationFields.map((f) => ( + {f.label}} + name={f.name} + rules={ + f.optional ? undefined : [{ required: true, message: `Please enter the ${f.label.toLowerCase()}` }] + } + > + {f.type === "password" ? ( + + ) : ( + + )} + + ))} + +
+ ) : ( + + )}
= ({ accessToken, userRole, userID, setShowAddCallbacksModal(false); setSelectedCallback(null); setSelectedCallbackParams([]); + setAddAccess({}); addForm.resetFields(); }} disabled={isAddingCallback} @@ -630,7 +789,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, Cancel - {isAddingCallback ? "Adding..." : "Add Callback"} + {isAddingCallback ? "Adding..." : "Add"}
@@ -701,13 +860,26 @@ const Settings: React.FC = ({ accessToken, userRole, userID, { setShowDeleteConfirmModal(false); setCallbackToDelete(null); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index f0774537fd9..fe88b56ddf9 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -57,6 +57,7 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeam: vi.fn(), + useTeams: vi.fn().mockReturnValue({ data: [] }), })); vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ @@ -956,6 +957,36 @@ describe("TeamInfoView", () => { ); }); }); + + it("omits organization_id from an unrelated save so the org is not re-joined", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ organization_id: "org-123", models: ["gpt-4"] }), + ); + vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0); + }); + + await user.click(screen.getByRole("tab", { name: "Settings" })); + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /edit settings/i })); + await waitFor(() => { + expect(screen.getByLabelText("Team Name")).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(networking.teamUpdateCall).toHaveBeenCalled(); + }); + const [, payload] = vi.mocked(networking.teamUpdateCall).mock.calls[0]; + expect(payload).not.toHaveProperty("organization_id"); + }); }); describe("metadata key-value editing", () => { diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index d4cf8ca2d27..7abc3d59c85 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -150,6 +150,7 @@ export interface TeamData { access_group_mcp_server_ids?: string[]; access_group_agent_ids?: string[]; access_group_details?: TeamAccessGroupModelGrant[]; + resolved_logging_exporters?: string[] | null; router_settings?: Record; guardrails?: string[]; policies?: string[]; @@ -247,6 +248,13 @@ const TeamInfoView: React.FC = ({ ); const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData; + + // Destinations that will receive this team's traces, resolved server-side by + // /team/info from credential_info.access. Names only, visible to every team viewer. + const scopedExportersForTeam = useMemo( + () => teamData?.team_info?.resolved_logging_exporters ?? [], + [teamData?.team_info], + ); const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]); const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]); @@ -884,6 +892,7 @@ const TeamInfoView: React.FC = ({ @@ -1721,6 +1730,7 @@ const TeamInfoView: React.FC = ({ ({ }), })); +// KeyInfoView's Logging Exporters select pulls credentials + orgs via react-query; +// mock both so this QueryClientProvider-free unit test of handleKeyUpdate runs. +vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({ + useCredentials: vi.fn().mockReturnValue({ data: { credentials: [] }, refetch: vi.fn() }), +})); + +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ data: [], refetch: vi.fn() }), +})); + vi.mock("@/app/(dashboard)/hooks/keys/useSetKeyBlockedState", () => ({ useSetKeyBlockedState: vi.fn().mockReturnValue({ mutate: vi.fn(), diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 36dc02f7bd0..ae47438992a 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -799,6 +799,7 @@ export function KeyEditView({ )} + (keyData); + const [lastRegeneratedAt, setLastRegeneratedAt] = useState(null); const [isRecentlyRegenerated, setIsRecentlyRegenerated] = useState(false); const [policyGuardrails, setPolicyGuardrails] = useState>({}); diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index b6d84b9365d..8edc7da0f5a 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -32,6 +32,11 @@ export const isProxyAdminRole = (role: string): boolean => { return role === "proxy_admin" || role === "Admin"; }; +// Roles allowed to read GET /credentials (proxy admin, plus the read-only admin viewer) +export const canReadCredentialsRole = (role: string | null | undefined): boolean => { + return role != null && (isProxyAdminRole(role) || role === "Admin Viewer" || role === "proxy_admin_viewer"); +}; + export const isUserTeamAdminForAnyTeam = (teams: Team[] | null, userID: string): boolean => { if (teams == null) { return false;