Prompt Management - Add table + prompt info page to UI (#13232)

* fix(create_key_button.tsx): add prompts on UI

* feat(key_management_endpoints.py): support adding prompt to key via `/key/update`

* fix(key_info_view.tsx): show existing prompts on key in key_info_view.tsx

* fix(key_edit_view.tsx): UX - disable premium feature for non-premium users

prevent accidental clicking

* fix(create_key_button.tsx): disable premium features behind flag, prevent errors

* feat(prompts.tsx): add new ui component to view created prompts

enables viewing prompts created on config

* feat(prompt_info.tsx): add component for viewing the prompt information
This commit is contained in:
Krish Dholakia 2025-08-02 19:34:18 -07:00 committed by GitHub
parent b79f55eec0
commit e47b30a76d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 662 additions and 11 deletions

View file

@ -514,6 +514,7 @@ class LiteLLMRoutes(enum.Enum):
"/user/daily/activity",
"/model/{model_id}/update",
"/prompt/list",
"/prompt/info",
] # routes that manage their own allowed/disallowed logic
## Org Admin Routes ##

View file

@ -1,6 +1,7 @@
from typing import Any, Optional, Union
from litellm.proxy._types import (
GenerateKeyRequest,
KeyRequestBase,
LiteLLM_ManagementEndpoint_MetadataFields_Premium,
LiteLLM_TeamTable,

View file

@ -855,6 +855,15 @@ async def prepare_key_update_data(
data: Union[UpdateKeyRequest, RegenerateKeyRequest],
existing_key_row: LiteLLM_VerificationToken,
):
for field in LiteLLM_ManagementEndpoint_MetadataFields_Premium:
if getattr(data, field, None) is not None:
_set_object_metadata_field(
object_data=data,
field_name=field,
value=getattr(data, field),
)
delattr(data, field)
data_json: dict = data.model_dump(exclude_unset=True)
data_json.pop("key", None)
data_json.pop("new_key", None)

View file

@ -4,11 +4,11 @@ CRUD ENDPOINTS FOR PROMPTS
from typing import List, Optional, cast
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.types.prompts.init_prompts import ListPromptsResponse
from litellm.types.prompts.init_prompts import ListPromptsResponse, PromptSpec
router = APIRouter()
@ -50,3 +50,43 @@ async def list_prompts(
)
else:
return ListPromptsResponse(prompts=[])
@router.get(
"/prompt/info",
tags=["Prompt Management"],
dependencies=[Depends(user_api_key_auth)],
response_model=PromptSpec,
)
async def get_prompt(
prompt_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Get info about a prompt
"""
from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY
## CHECK IF USER HAS ACCESS TO PROMPT
prompts: Optional[List[str]] = None
if user_api_key_dict.metadata is not None:
prompts = cast(
Optional[List[str]], user_api_key_dict.metadata.get("prompts", None)
)
if prompts is not None and prompt_id not in prompts:
raise HTTPException(status_code=400, detail=f"Prompt {prompt_id} not found")
if user_api_key_dict.user_role is not None and (
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
):
pass
else:
raise HTTPException(
status_code=403,
detail=f"You are not authorized to access this prompt. Your role - {user_api_key_dict.user_role}, Your key's prompts - {prompts}",
)
prompt_spec = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(prompt_id)
if prompt_spec is None:
raise HTTPException(status_code=400, detail=f"Prompt {prompt_id} not found")
return prompt_spec

View file

@ -6,7 +6,11 @@ from typing import Callable, Dict, Optional
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_prompt_management import CustomPromptManagement
from litellm.types.prompts.init_prompts import PromptLiteLLMParams, PromptSpec
from litellm.types.prompts.init_prompts import (
PromptInfo,
PromptLiteLLMParams,
PromptSpec,
)
prompt_initializer_registry = {}
@ -147,6 +151,7 @@ class InMemoryPromptRegistry:
parsed_prompt = PromptSpec(
prompt_id=prompt_id,
litellm_params=litellm_params,
prompt_info=PromptInfo(prompt_type="config"),
)
# store references to the prompt in memory

View file

@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict
from typing_extensions import Required, TypedDict
@ -12,6 +12,12 @@ class SupportedPromptIntegrations(str, Enum):
CUSTOM = "custom"
class PromptInfo(BaseModel):
prompt_type: Literal["config", "db"]
model_config = ConfigDict(extra="allow", protected_namespaces=())
class PromptLiteLLMParams(BaseModel):
prompt_id: str
prompt_integration: str
@ -22,7 +28,7 @@ class PromptLiteLLMParams(BaseModel):
class PromptSpec(TypedDict, total=False):
prompt_id: Required[str]
litellm_params: Required[PromptLiteLLMParams]
prompt_info: Optional[Dict]
prompt_info: Optional[PromptInfo]
created_at: Optional[datetime]
updated_at: Optional[datetime]

View file

@ -32,6 +32,7 @@ import {
} from "@/components/networking";
import { Organization } from "@/components/networking";
import GuardrailsPanel from "@/components/guardrails";
import PromptsPanel from "@/components/prompts";
import TransformRequestPanel from "@/components/transform_request";
import { fetchUserModels } from "@/components/create_key_button";
import { fetchTeams } from "@/components/common_components/fetch_teams";
@ -361,6 +362,11 @@ export default function CreateKeyPage() {
accessToken={accessToken}
userRole={userRole}
/>
) : page == "prompts" ? (
<PromptsPanel
accessToken={accessToken}
userRole={userRole}
/>
) : page == "transform-request" ? (
<TransformRequestPanel accessToken={accessToken} />
) : page == "general-settings" ? (

View file

@ -89,6 +89,7 @@ const Sidebar: React.FC<SidebarProps> = ({
icon: <ExperimentOutlined />,
children: [
{ key: "9", page: "caching", label: "Caching", icon: <DatabaseOutlined />, roles: all_admin_roles },
{ key: "25", page: "prompts", label: "Prompts", icon: <FileTextOutlined />, roles: all_admin_roles },
{ key: "10", page: "budgets", label: "Budgets", icon: <BankOutlined />, roles: all_admin_roles },
{ key: "20", page: "transform-request", label: "API Playground", icon: <ApiOutlined />, roles: [...all_admin_roles, ...internalUserRoles] },
{ key: "19", page: "tag-management", label: "Tag Management", icon: <TagsOutlined />, roles: all_admin_roles },

View file

@ -75,13 +75,19 @@ export interface Model {
model_info: Object | null;
}
interface PromptSpec {
prompt_id: string;
litellm_params: Object;
prompt_info: Object;
interface PromptInfo {
prompt_type: string;
}
interface ListPromptsResponse {
export interface PromptSpec {
prompt_id: string;
litellm_params: Object;
prompt_info: PromptInfo;
created_at?: string
updated_at?: string
}
export interface ListPromptsResponse {
prompts: PromptSpec[];
}
@ -4917,6 +4923,31 @@ export const getPromptsList = async (accessToken: String) : Promise<ListPromptsR
}
};
export const getPromptInfo = async (accessToken: String, promptId: string): Promise<PromptSpec> => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/prompt/info?prompt_id=${promptId}` : `/prompt/info?prompt_id=${promptId}`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to get prompt info:", error);
throw error;
}
};
export const createGuardrailCall = async (
accessToken: string,
guardrailData: any

View file

@ -0,0 +1,71 @@
import React, { useState, useEffect } from "react"
import { Card, Text } from "@tremor/react"
import { getPromptsList, PromptSpec, ListPromptsResponse } from "./networking"
import PromptTable from "./prompts/prompt_table"
import PromptInfoView from "./prompts/prompt_info"
import { isAdminRole } from "@/utils/roles"
interface PromptsProps {
accessToken: string | null
userRole?: string
}
const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
const [promptsList, setPromptsList] = useState<PromptSpec[]>([])
const [isLoading, setIsLoading] = useState(false)
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null)
const isAdmin = userRole ? isAdminRole(userRole) : false
const fetchPrompts = async () => {
if (!accessToken) {
return
}
setIsLoading(true)
try {
const response: ListPromptsResponse = await getPromptsList(accessToken)
console.log(`prompts: ${JSON.stringify(response)}`)
setPromptsList(response.prompts)
} catch (error) {
console.error("Error fetching prompts:", error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchPrompts()
}, [accessToken])
const handlePromptClick = (promptId: string) => {
setSelectedPromptId(promptId)
}
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
{selectedPromptId ? (
<PromptInfoView
promptId={selectedPromptId}
onClose={() => setSelectedPromptId(null)}
accessToken={accessToken}
isAdmin={isAdmin}
/>
) : (
<>
<div className="flex justify-between items-center mb-4">
<Text className="text-lg font-semibold">Prompts</Text>
</div>
<PromptTable
promptsList={promptsList}
isLoading={isLoading}
onPromptClick={handlePromptClick}
/>
</>
)}
</div>
)
}
export default PromptsPanel

View file

@ -0,0 +1,73 @@
# Prompts Component
This component provides a view-only interface for viewing prompts in the LiteLLM dashboard, similar to the guardrails component.
## Components
### PromptsPanel (`prompts.tsx`)
- Main component that displays the prompts list
- Fetches prompts using the `getPromptsList` API call
- Handles loading states and error handling
### PromptTable (`prompt_table.tsx`)
- Table component that displays prompts data
- Uses Tanstack Table for sorting and filtering
- Shows: Prompt ID, Created At, Updated At, Type
- Supports clicking on prompt IDs to open detailed view
### PromptInfoView (`prompt_info.tsx`)
- Detail view component for individual prompts
- Shows comprehensive prompt information including metadata and parameters
- Three-tab interface: **Overview**, **Details** (admin-only), and **Raw JSON**
- **Overview**: Shows formatted prompt information with key details
- **Details**: Shows structured breakdown of all prompt data (admin users only)
- **Raw JSON**: Shows exactly what the API returns with copy-to-clipboard functionality
- Includes copy-to-clipboard functionality for prompt ID and raw JSON
- Similar structure to GuardrailInfoView component
## Usage
The component is integrated into the main application at:
- **Navigation**: Available in the left sidebar under "Experimental" > "Prompts" (admin role required)
- **Routing**: Accessible via `?page=prompts` URL parameter
- **API**: Uses `getPromptsList` and `getPromptInfo` functions from `networking.tsx`
- **Detail View**: Click any prompt ID to view detailed information
## Props
```typescript
interface PromptsProps {
accessToken: string | null
userRole?: string
}
```
## Data Structure
The component expects prompts with the following structure:
```typescript
interface PromptItem {
prompt_id?: string
prompt_name: string | null
prompt_info: Record<string, any>
created_at?: string
updated_at?: string
}
```
## Integration
The component is fully integrated into the main application:
1. **Left Navigation**: Added to `leftnav.tsx` with `FileTextOutlined` icon
2. **Main Routing**: Added to `page.tsx` with proper routing logic
3. **Permissions**: Restricted to admin roles (same as guardrails)
## Future Enhancements
- ✅ Add prompt detail view (similar to GuardrailInfoView) - **COMPLETED**
- Add create/edit/delete functionality
- Add bulk operations
- Add search and filtering capabilities
- Add export functionality

View file

@ -0,0 +1,2 @@
export { default as PromptTable } from "./prompt_table"
export { default as PromptInfoView } from "./prompt_info"

View file

@ -0,0 +1,231 @@
import React, { useState, useEffect } from "react"
import {
Card,
Title,
Text,
Grid,
Badge,
Button as TremorButton,
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
} from "@tremor/react"
import { Button, message, Tooltip } from "antd"
import { ArrowLeftIcon } from "@heroicons/react/outline"
import { getPromptInfo, PromptSpec } from "@/components/networking"
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"
import { CheckIcon, CopyIcon } from "lucide-react"
export interface PromptInfoProps {
promptId: string
onClose: () => void
accessToken: string | null
isAdmin: boolean
}
const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessToken, isAdmin }) => {
const [promptData, setPromptData] = useState<PromptSpec | null>(null)
const [rawApiResponse, setRawApiResponse] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({})
const fetchPromptInfo = async () => {
try {
setLoading(true)
if (!accessToken) return
const response = await getPromptInfo(accessToken, promptId)
setPromptData(response)
setRawApiResponse(response) // Store the raw response for the Raw JSON tab
} catch (error) {
message.error("Failed to load prompt information")
console.error("Error fetching prompt info:", error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchPromptInfo()
}, [promptId, accessToken])
if (loading) {
return <div className="p-4">Loading...</div>
}
if (!promptData) {
return <div className="p-4">Prompt not found</div>
}
// Format date helper function
const formatDate = (dateString?: string) => {
if (!dateString) return "-"
const date = new Date(dateString)
return date.toLocaleString()
}
const copyToClipboard = async (text: string | null | undefined, key: string) => {
const success = await utilCopyToClipboard(text)
if (success) {
setCopiedStates((prev) => ({ ...prev, [key]: true }))
setTimeout(() => {
setCopiedStates((prev) => ({ ...prev, [key]: false }))
}, 2000)
}
}
return (
<div className="p-4">
<div>
<TremorButton icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
Back to Prompts
</TremorButton>
<Title>Prompt Details</Title>
<div className="flex items-center cursor-pointer">
<Text className="text-gray-500 font-mono">{promptData.prompt_id}</Text>
<Button
type="text"
size="small"
icon={copiedStates["prompt-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
onClick={() => copyToClipboard(promptData.prompt_id, "prompt-id")}
className={`left-2 z-10 transition-all duration-200 ${
copiedStates["prompt-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
/>
</div>
</div>
<TabGroup>
<TabList className="mb-4">
<Tab key="overview">Overview</Tab>
{isAdmin ? <Tab key="details">Details</Tab> : <></>}
<Tab key="raw-json">Raw JSON</Tab>
</TabList>
<TabPanels>
{/* Overview Panel */}
<TabPanel>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
<Card>
<Text>Prompt ID</Text>
<div className="mt-2">
<Title className="font-mono text-sm">{promptData.prompt_id}</Title>
</div>
</Card>
<Card>
<Text>Prompt Type</Text>
<div className="mt-2">
<Title>{promptData.prompt_info?.prompt_type || "-"}</Title>
<Badge color="blue" className="mt-1">
{promptData.prompt_info?.prompt_type || "Unknown"}
</Badge>
</div>
</Card>
<Card>
<Text>Created At</Text>
<div className="mt-2">
<Title>{formatDate(promptData.created_at)}</Title>
<Text>Last Updated: {formatDate(promptData.updated_at)}</Text>
</div>
</Card>
</Grid>
{promptData.litellm_params && Object.keys(promptData.litellm_params).length > 0 && (
<Card className="mt-6">
<Text className="font-medium">LiteLLM Parameters</Text>
<div className="mt-2 p-3 bg-gray-50 rounded-md">
<pre className="text-xs text-gray-800 whitespace-pre-wrap">
{JSON.stringify(promptData.litellm_params, null, 2)}
</pre>
</div>
</Card>
)}
</TabPanel>
{/* Details Panel (only for admins) */}
{isAdmin && (
<TabPanel>
<Card>
<Title className="mb-4">Prompt Details</Title>
<div className="space-y-4">
<div>
<Text className="font-medium">Prompt ID</Text>
<div className="font-mono text-sm bg-gray-50 p-2 rounded">{promptData.prompt_id}</div>
</div>
<div>
<Text className="font-medium">Prompt Type</Text>
<div>{promptData.prompt_info?.prompt_type || "-"}</div>
</div>
<div>
<Text className="font-medium">Created At</Text>
<div>{formatDate(promptData.created_at)}</div>
</div>
<div>
<Text className="font-medium">Last Updated</Text>
<div>{formatDate(promptData.updated_at)}</div>
</div>
<div>
<Text className="font-medium">LiteLLM Parameters</Text>
<div className="mt-2 p-3 bg-gray-50 rounded-md border">
<pre className="text-xs text-gray-800 whitespace-pre-wrap overflow-auto max-h-96">
{JSON.stringify(promptData.litellm_params, null, 2)}
</pre>
</div>
</div>
<div>
<Text className="font-medium">Prompt Info</Text>
<div className="mt-2 p-3 bg-gray-50 rounded-md border">
<pre className="text-xs text-gray-800 whitespace-pre-wrap">
{JSON.stringify(promptData.prompt_info, null, 2)}
</pre>
</div>
</div>
</div>
</Card>
</TabPanel>
)}
{/* Raw JSON Panel */}
<TabPanel>
<Card>
<div className="flex justify-between items-center mb-4">
<Title>Raw API Response</Title>
<Button
type="text"
size="small"
icon={copiedStates["raw-json"] ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
onClick={() => copyToClipboard(JSON.stringify(rawApiResponse, null, 2), "raw-json")}
className={`transition-all duration-200 ${
copiedStates["raw-json"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
>
{copiedStates["raw-json"] ? "Copied!" : "Copy JSON"}
</Button>
</div>
<div className="p-4 bg-gray-50 rounded-md border overflow-auto">
<pre className="text-xs text-gray-800 whitespace-pre-wrap">
{JSON.stringify(rawApiResponse, null, 2)}
</pre>
</div>
</Card>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
)
}
export default PromptInfoView

View file

@ -0,0 +1,174 @@
import React, { useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Button } from "@tremor/react"
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline"
import { Tooltip } from "antd"
import {PromptSpec} from "@/components/networking"
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table"
interface PromptTableProps {
promptsList: PromptSpec[]
isLoading: boolean
onPromptClick?: (id: string) => void
}
const PromptTable: React.FC<PromptTableProps> = ({
promptsList,
isLoading,
onPromptClick,
}) => {
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }])
// Format date helper function
const formatDate = (dateString?: string) => {
if (!dateString) return "-"
const date = new Date(dateString)
return date.toLocaleString()
}
const columns: ColumnDef<PromptSpec>[] = [
{
header: "Prompt ID",
accessorKey: "prompt_id",
cell: (info: any) => (
<Tooltip title={String(info.getValue() || "")}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
onClick={() => info.getValue() && onPromptClick?.(info.getValue())}
>
{info.getValue() ? `${String(info.getValue()).slice(0, 7)}...` : ""}
</Button>
</Tooltip>
),
},
{
header: "Created At",
accessorKey: "created_at",
cell: ({ row }) => {
const prompt = row.original
return (
<Tooltip title={prompt.created_at}>
<span className="text-xs">{formatDate(prompt.created_at)}</span>
</Tooltip>
)
},
},
{
header: "Updated At",
accessorKey: "updated_at",
cell: ({ row }) => {
const prompt = row.original
return (
<Tooltip title={prompt.updated_at}>
<span className="text-xs">{formatDate(prompt.updated_at)}</span>
</Tooltip>
)
},
},
{
header: "Type",
accessorKey: "prompt_info.prompt_type",
cell: ({ row }) => {
const prompt = row.original
return (
<Tooltip title={prompt.prompt_info.prompt_type}>
<span className="text-xs">{prompt.prompt_info.prompt_type}</span>
</Tooltip>
)
},
},
]
const table = useReactTable({
data: promptsList,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
})
return (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className="py-1 h-8"
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</div>
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading...</p>
</div>
</TableCell>
</TableRow>
) : promptsList.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No prompts found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)
}
export default PromptTable