Merge pull request #23036 from BerriAI/litellm_hashicorp_vault_frontend

Litellm hashicorp vault frontend
This commit is contained in:
ryan-crabbe 2026-03-07 11:04:14 -08:00 • committed by GitHub
commit fe1ee4b039
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 617 additions and 0 deletions

View file

@ -0,0 +1,86 @@
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage } from "@/components/networking";
export const getHashicorpVaultConfig = async (accessToken: string) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault`
: `/config_overrides/hashicorp_vault`;
const response = await fetch(url, {
method: "GET",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
};
export const updateHashicorpVaultConfig = async (
accessToken: string,
config: Record<string, any>,
) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault`
: `/config_overrides/hashicorp_vault`;
const response = await fetch(url, {
method: "POST",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
};
export const deleteHashicorpVaultConfig = async (accessToken: string) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault`
: `/config_overrides/hashicorp_vault`;
const response = await fetch(url, {
method: "DELETE",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
};
export const testHashicorpVaultConnection = async (accessToken: string) => {
const proxyBaseUrl = getProxyBaseUrl();
const url = proxyBaseUrl
? `${proxyBaseUrl}/config_overrides/hashicorp_vault/test_connection`
: `/config_overrides/hashicorp_vault/test_connection`;
const response = await fetch(url, {
method: "POST",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
};

View file

@ -0,0 +1,19 @@
import { deleteHashicorpVaultConfig } from "./hashicorpVaultApi";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { hashicorpVaultKeys } from "./useHashicorpVaultConfig";
export const useDeleteHashicorpVaultConfig = (accessToken: string | null) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!accessToken) {
throw new Error("Access token is required");
}
return deleteHashicorpVaultConfig(accessToken);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: hashicorpVaultKeys.all });
},
});
};

View file

@ -0,0 +1,23 @@
import { getHashicorpVaultConfig } from "./hashicorpVaultApi";
import { useQuery } from "@tanstack/react-query";
import useAuthorized from "../useAuthorized";
import { createQueryKeys } from "../common/queryKeysFactory";
export const hashicorpVaultKeys = createQueryKeys("hashicorpVaultConfig");
export const useHashicorpVaultConfig = () => {
const { accessToken } = useAuthorized();
return useQuery<Record<string, any>>({
queryKey: hashicorpVaultKeys.list({}),
queryFn: async () => {
if (!accessToken) {
throw new Error("Access token is required");
}
return getHashicorpVaultConfig(accessToken);
},
enabled: !!accessToken,
staleTime: 60 * 60 * 1000,
gcTime: 60 * 60 * 1000,
});
};

View file

@ -0,0 +1,19 @@
import { updateHashicorpVaultConfig } from "./hashicorpVaultApi";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { hashicorpVaultKeys } from "./useHashicorpVaultConfig";
export const useUpdateHashicorpVaultConfig = (accessToken: string | null) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (config: Record<string, any>) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return updateHashicorpVaultConfig(accessToken, config);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: hashicorpVaultKeys.all });
},
});
};

View file

@ -23,6 +23,7 @@ import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "./
import SCIMConfig from "./SCIM";
import SSOSettings from "./Settings/AdminSettings/SSOSettings/SSOSettings";
import UISettings from "./Settings/AdminSettings/UISettings/UISettings";
import HashicorpVault from "./Settings/AdminSettings/HashicorpVault/HashicorpVault";
import SSOModals from "./SSOModals";
import UIAccessControlForm from "./UIAccessControlForm";
@ -361,6 +362,11 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
),
children: <UISettings />,
},
{
key: "hashicorp-vault",
label: "Hashicorp Vault",
children: <HashicorpVault />,
},
];
return (

View file

@ -0,0 +1,171 @@
"use client";
import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig";
import { useUpdateHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import NotificationManager from "@/components/molecules/notifications_manager";
import { Button, Divider, Form, Input, Modal, Space, Typography } from "antd";
import React, { useEffect } from "react";
import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants";
interface FieldGroup {
title: string;
subtitle?: string;
fields: string[];
}
const FIELD_GROUPS: FieldGroup[] = [
{
title: "Connection",
fields: ["vault_addr", "vault_namespace", "vault_mount_name", "vault_path_prefix"],
},
{
title: "Token Authentication",
subtitle: "Use a Vault token to authenticate. Only one auth method is required.",
fields: ["vault_token"],
},
{
title: "AppRole Authentication",
subtitle: "Use AppRole credentials to authenticate. Only one auth method is required.",
fields: ["approle_role_id", "approle_secret_id", "approle_mount_path"],
},
{
title: "TLS",
subtitle: "Optional client certificate for mTLS.",
fields: ["client_cert", "client_key", "vault_cert_role"],
},
];
interface EditHashicorpVaultModalProps {
isVisible: boolean;
onCancel: () => void;
onSuccess: () => void;
}
const EditHashicorpVaultModal: React.FC<EditHashicorpVaultModalProps> = ({
isVisible,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const { accessToken } = useAuthorized();
const { data } = useHashicorpVaultConfig();
const { mutate, isPending } = useUpdateHashicorpVaultConfig(accessToken);
const schema = data?.field_schema;
const properties = schema?.properties ?? {};
const rawValues = data?.values ?? {};
useEffect(() => {
if (isVisible && data) {
form.resetFields();
// Only set non-sensitive fields — sensitive ones show as placeholders
const formValues: Record<string, any> = {};
for (const [key, value] of Object.entries(rawValues)) {
if (!SENSITIVE_FIELDS.has(key)) {
formValues[key] = value;
}
}
form.setFieldsValue(formValues);
}
}, [isVisible, data, form]);
const handleSubmit = (formValues: Record<string, any>) => {
const config: Record<string, any> = {};
for (const [key, value] of Object.entries(formValues)) {
if (value !== undefined && value !== null && value !== "") {
// Non-empty value → update
config[key] = value;
} else if (!SENSITIVE_FIELDS.has(key)) {
// Non-sensitive field cleared → send "" to clear it on the backend
config[key] = "";
}
// Sensitive field left blank → omit from payload (keep existing)
}
mutate(config, {
onSuccess: () => {
NotificationManager.success("Hashicorp Vault configuration updated successfully");
onSuccess();
},
onError: (err) => {
NotificationManager.fromBackend(err);
},
});
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
const renderField = (fieldName: string) => {
const fieldSchema = properties[fieldName];
if (!fieldSchema) return null;
const rules =
fieldName === "vault_addr"
? [{ pattern: /^https?:\/\/.+/, message: "Must start with http:// or https://" }]
: undefined;
const isSensitive = SENSITIVE_FIELDS.has(fieldName);
const existingValue = rawValues[fieldName];
const hasExistingValue = isSensitive && existingValue != null && existingValue !== "";
const placeholder = hasExistingValue
? `Leave blank to keep existing (${existingValue})`
: fieldSchema?.description;
return (
<Form.Item
key={fieldName}
name={fieldName}
label={FIELD_LABELS[fieldName] ?? fieldName}
rules={rules}
>
{isSensitive ? (
<Input.Password placeholder={placeholder} />
) : (
<Input placeholder={fieldSchema?.description} />
)}
</Form.Item>
);
};
return (
<Modal
title="Edit Hashicorp Vault Configuration"
open={isVisible}
width={700}
footer={
<Space>
<Button onClick={handleCancel} disabled={isPending}>
Cancel
</Button>
<Button type="primary" loading={isPending} onClick={() => form.submit()}>
{isPending ? "Saving..." : "Save"}
</Button>
</Space>
}
onCancel={handleCancel}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
{FIELD_GROUPS.map((group, index) => (
<div key={group.title}>
{index > 0 && <Divider />}
<Typography.Title level={5} style={{ marginBottom: 4 }}>
{group.title}
</Typography.Title>
{group.subtitle && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
{group.subtitle}
</Typography.Paragraph>
)}
{group.fields.map(renderField)}
</div>
))}
</Form>
</Modal>
);
};
export default EditHashicorpVaultModal;

View file

@ -0,0 +1,243 @@
"use client";
import { useState } from "react";
import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig";
import { useDeleteHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig";
import { useUpdateHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import NotificationManager from "@/components/molecules/notifications_manager";
import { testHashicorpVaultConnection } from "@/app/(dashboard)/hooks/configOverrides/hashicorpVaultApi";
import { Alert, Button, Card, Descriptions, Flex, Skeleton, Space, Typography } from "antd";
import { Edit, KeyRound, PlugZap, Trash2 } from "lucide-react";
import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants";
import EditHashicorpVaultModal from "./EditHashicorpVaultModal";
import HashicorpVaultEmptyPlaceholder from "./HashicorpVaultEmptyPlaceholder";
const { Title, Text } = Typography;
function detectAuthMethod(values: Record<string, any>): string {
if (values.approle_role_id || values.approle_secret_id) return "AppRole";
if (values.client_cert && values.client_key) return "TLS Certificate";
if (values.vault_token) return "Token";
return "None";
}
const descriptionsConfig = {
column: { xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 },
};
export default function HashicorpVault() {
const { accessToken } = useAuthorized();
const { data, isLoading, isError, error } = useHashicorpVaultConfig();
const { mutate: deleteConfig, isPending: isDeleting } = useDeleteHashicorpVaultConfig(accessToken);
const { mutate: updateConfig, isPending: isClearingField } = useUpdateHashicorpVaultConfig(accessToken);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [clearingField, setClearingField] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);
const rawValues = data?.values ?? {};
const isConfigured = Boolean(rawValues.vault_addr);
const handleTestConnection = async () => {
if (!accessToken) return;
setIsTesting(true);
try {
const result = await testHashicorpVaultConnection(accessToken);
NotificationManager.success(result.message || "Connection to Vault successful!");
} catch (err) {
NotificationManager.fromBackend(err);
} finally {
setIsTesting(false);
}
};
const handleDelete = () => {
deleteConfig(undefined, {
onSuccess: () => {
NotificationManager.success("Hashicorp Vault configuration deleted");
setIsDeleteModalOpen(false);
},
onError: (err) => {
NotificationManager.fromBackend(err);
},
});
};
const handleClearField = () => {
if (!clearingField) return;
updateConfig({ [clearingField]: "" }, {
onSuccess: () => {
NotificationManager.success(`${FIELD_LABELS[clearingField] ?? clearingField} cleared`);
setClearingField(null);
},
onError: (err) => {
NotificationManager.fromBackend(err);
},
});
};
const renderValue = (key: string) => {
const value = rawValues[key];
if (!value) {
return <span className="text-gray-400 italic">Not configured</span>;
}
if (SENSITIVE_FIELDS.has(key)) {
return (
<Flex justify="space-between" align="center">
<Text className="font-mono text-gray-600">{value}</Text>
<Button
type="text"
size="small"
danger
icon={<Trash2 className="w-3.5 h-3.5" />}
onClick={() => setClearingField(key)}
/>
</Flex>
);
}
return <Text className="font-mono text-gray-600">{value}</Text>;
};
const renderSettings = () => {
// Only show fields that have values, plus auth method
const fieldsToShow = Object.entries(rawValues).filter(
([_, value]) => value != null && value !== ""
);
if (fieldsToShow.length === 0) return null;
return (
<Descriptions bordered {...descriptionsConfig}>
<Descriptions.Item label="Auth Method">
<Text>{detectAuthMethod(rawValues)}</Text>
</Descriptions.Item>
{fieldsToShow.map(([key]) => (
<Descriptions.Item key={key} label={FIELD_LABELS[key] ?? key}>
{renderValue(key)}
</Descriptions.Item>
))}
</Descriptions>
);
};
return (
<>
{isLoading ? (
<Card>
<Skeleton active />
</Card>
) : isError ? (
<Card>
<Alert
type="error"
message="Could not load Hashicorp Vault configuration"
description={error instanceof Error ? error.message : undefined}
/>
</Card>
) : (
<Card>
<Space direction="vertical" size="large" className="w-full">
{/* Header */}
<Flex justify="space-between" align="center">
<Flex align="center" gap={12}>
<KeyRound className="w-6 h-6 text-gray-400" />
<div>
<Title level={3} style={{ marginBottom: 0 }}>Hashicorp Vault</Title>
<Text type="secondary">Manage secret manager configuration</Text>
</div>
</Flex>
<Space>
{isConfigured && (
<>
<Button
icon={<PlugZap className="w-4 h-4" />}
loading={isTesting}
onClick={handleTestConnection}
>
Test Connection
</Button>
<Button
icon={<Edit className="w-4 h-4" />}
onClick={() => setIsEditModalVisible(true)}
>
Edit Configuration
</Button>
<Button
danger
icon={<Trash2 className="w-4 h-4" />}
onClick={() => setIsDeleteModalOpen(true)}
>
Delete Configuration
</Button>
</>
)}
</Space>
</Flex>
{isConfigured && (
<Alert
type="info"
showIcon
message={'Secrets must be stored with the field name "key"'}
description={
<>
<Text code>vault kv put secret/SECRET_NAME key=secret_value</Text>
<br />
<Typography.Link
href="https://docs.litellm.ai/docs/secret_managers/hashicorp_vault"
target="_blank"
>
View documentation
</Typography.Link>
</>
}
/>
)}
{isConfigured ? (
renderSettings()
) : (
<HashicorpVaultEmptyPlaceholder onAdd={() => setIsEditModalVisible(true)} />
)}
</Space>
</Card>
)}
<EditHashicorpVaultModal
isVisible={isEditModalVisible}
onCancel={() => setIsEditModalVisible(false)}
onSuccess={() => setIsEditModalVisible(false)}
/>
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Hashicorp Vault Configuration?"
message="Models using Vault secrets will lose access to their API keys until a new configuration is saved."
resourceInformationTitle="Vault Configuration"
resourceInformation={[
{ label: "Vault Address", value: rawValues.vault_addr },
]}
onCancel={() => setIsDeleteModalOpen(false)}
onOk={handleDelete}
confirmLoading={isDeleting}
/>
<DeleteResourceModal
isOpen={clearingField !== null}
title={`Clear ${clearingField ? (FIELD_LABELS[clearingField] ?? clearingField) : ""}?`}
message="This will remove the stored value."
resourceInformationTitle="Field"
resourceInformation={[
{ label: "Field", value: clearingField ? (FIELD_LABELS[clearingField] ?? clearingField) : "" },
]}
onCancel={() => setClearingField(null)}
onOk={handleClearField}
confirmLoading={isClearingField}
/>
</>
);
}

View file

@ -0,0 +1,30 @@
import { Empty, Typography, Button } from "antd";
const { Title, Paragraph } = Typography;
interface HashicorpVaultEmptyPlaceholderProps {
onAdd: () => void;
}
export default function HashicorpVaultEmptyPlaceholder({ onAdd }: HashicorpVaultEmptyPlaceholderProps) {
return (
<div className="bg-white p-12 rounded-lg border border-dashed border-gray-300 text-center w-full">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div className="space-y-2">
<Title level={4}>No Vault Configuration Found</Title>
<Paragraph type="secondary" className="max-w-md mx-auto">
Configure Hashicorp Vault to securely manage provider API keys and secrets
for your LiteLLM deployment.
</Paragraph>
</div>
}
>
<Button type="primary" size="large" onClick={onAdd} className="flex items-center gap-2 mx-auto mt-4">
Configure Vault
</Button>
</Empty>
</div>
);
}

View file

@ -0,0 +1,19 @@
export const SENSITIVE_FIELDS = new Set([
"vault_token",
"approle_secret_id",
"client_key",
]);
export const FIELD_LABELS: Record<string, string> = {
vault_addr: "Vault Address",
vault_namespace: "Namespace",
vault_mount_name: "KV Mount Name",
vault_path_prefix: "Path Prefix",
vault_token: "Token",
approle_role_id: "Role ID",
approle_secret_id: "Secret ID",
approle_mount_path: "Mount Path",
client_cert: "Client Certificate",
client_key: "Client Key",
vault_cert_role: "Certificate Role",
};

View file

@ -8880,6 +8880,7 @@ export const updateUiSettings = async (accessToken: string, settings: Record<str
return data;
};
// ============================================================
// Claude Code Marketplace Networking Functions
// ============================================================