From 97d9da93e01585252f518d010a990df046bd4405 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 21 Nov 2025 08:54:52 -0800 Subject: [PATCH] [Feat] Prompt Management - Allow viewing version history (#16901) * TestPromptRequest * add prompts/test endpoint for testing prompt * TestPromptTestEndpoint * feat: working v1 of this ui * workig prompt endpoints * add chat ui for prompts * add conversation panel * add init chat ui * allow clicking edit prompt * fix use get_base_prompt_id * add endpoints for viewing prompt versions * TestPromptVersioning * add getPromptVersions * add VersionHistorySidePanel * allow viewing version history * add version history --- litellm/proxy/prompts/prompt_endpoints.py | 167 ++++++++++++- .../proxy/prompts/test_prompt_endpoints.py | 222 ++++++++++++++++++ .../src/components/networking.tsx | 29 +++ .../src/components/prompts.tsx | 13 + .../prompt_editor_view/PromptEditorHeader.tsx | 24 +- .../VersionHistorySidePanel.tsx | 123 ++++++++++ .../prompts/prompt_editor_view/index.tsx | 71 +++++- .../prompts/prompt_editor_view/types.ts | 1 + .../prompts/prompt_editor_view/utils.ts | 87 ++++++- .../src/components/prompts/prompt_info.tsx | 15 +- 10 files changed, 731 insertions(+), 21 deletions(-) create mode 100644 tests/test_litellm/proxy/prompts/test_prompt_endpoints.py create mode 100644 ui/litellm-dashboard/src/components/prompts/prompt_editor_view/VersionHistorySidePanel.tsx diff --git a/litellm/proxy/prompts/prompt_endpoints.py b/litellm/proxy/prompts/prompt_endpoints.py index 373f993d3e3..8fe5adf1e22 100644 --- a/litellm/proxy/prompts/prompt_endpoints.py +++ b/litellm/proxy/prompts/prompt_endpoints.py @@ -33,6 +33,77 @@ from litellm.types.proxy.prompt_endpoints import TestPromptRequest router = APIRouter() +def get_base_prompt_id(prompt_id: str) -> str: + """ + Extract the base prompt ID by stripping the version suffix if present. + + Args: + prompt_id: Prompt ID that may include version suffix (e.g., "jack_success.v1") + + Returns: + Base prompt ID without version suffix (e.g., "jack_success") + + Examples: + >>> get_base_prompt_id("jack_success.v1") + "jack_success" + >>> get_base_prompt_id("jack_success") + "jack_success" + """ + return prompt_id.split(".v")[0] if ".v" in prompt_id else prompt_id + + +def get_version_number(prompt_id: str) -> int: + """ + Extract the version number from a versioned prompt ID. + + Args: + prompt_id: Prompt ID that may include version suffix (e.g., "jack_success.v2") + + Returns: + Version number (defaults to 1 if no version suffix or invalid format) + + Examples: + >>> get_version_number("jack_success.v2") + 2 + >>> get_version_number("jack_success") + 1 + """ + if ".v" in prompt_id: + version_str = prompt_id.split(".v")[1] + try: + return int(version_str) + except ValueError: + return 1 + return 1 + + +def get_latest_prompt_versions(prompts: List[PromptSpec]) -> List[PromptSpec]: + """ + Filter a list of prompts to return only the latest version of each unique prompt. + + Args: + prompts: List of PromptSpec objects + + Returns: + List of PromptSpec objects with only the latest version of each prompt + """ + latest_prompts: Dict[str, PromptSpec] = {} + + for prompt in prompts: + base_id = get_base_prompt_id(prompt_id=prompt.prompt_id) + version = get_version_number(prompt_id=prompt.prompt_id) + + # Keep the prompt with the highest version number + if base_id not in latest_prompts: + latest_prompts[base_id] = prompt + else: + existing_version = get_version_number(prompt_id=latest_prompts[base_id].prompt_id) + if version > existing_version: + latest_prompts[base_id] = prompt + + return list(latest_prompts.values()) + + async def get_next_version_for_prompt(prisma_client, prompt_id: str) -> int: """ Get the next version number for a prompt. @@ -171,13 +242,89 @@ async def list_prompts( user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value ): - return ListPromptsResponse( - prompts=list(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.values()) - ) + # Get all prompts and filter to show only the latest version of each + all_prompts = list(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.values()) + latest_prompts = get_latest_prompt_versions(prompts=all_prompts) + return ListPromptsResponse(prompts=latest_prompts) else: return ListPromptsResponse(prompts=[]) +@router.get( + "/prompts/{prompt_id}/versions", + tags=["Prompt Management"], + dependencies=[Depends(user_api_key_auth)], + response_model=ListPromptsResponse, +) +async def get_prompt_versions( + prompt_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get all versions of a specific prompt by base prompt ID + + 👉 [Prompt docs](https://docs.litellm.ai/docs/proxy/prompt_management) + + Example Request: + ```bash + curl -X GET "http://localhost:4000/prompts/jack_success/versions" \\ + -H "Authorization: Bearer " + ``` + + Example Response: + ```json + { + "prompts": [ + { + "prompt_id": "jack_success.v1", + "litellm_params": {...}, + "prompt_info": {"prompt_type": "db"}, + "created_at": "2023-11-09T12:34:56.789Z", + "updated_at": "2023-11-09T12:34:56.789Z" + }, + { + "prompt_id": "jack_success.v2", + "litellm_params": {...}, + "prompt_info": {"prompt_type": "db"}, + "created_at": "2023-11-09T13:45:12.345Z", + "updated_at": "2023-11-09T13:45:12.345Z" + } + ] + } + ``` + """ + from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY + + # Only allow proxy admins to view version history + if user_api_key_dict.user_role is None or ( + user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN + and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value + ): + raise HTTPException( + status_code=403, detail="Only proxy admins can view prompt versions" + ) + + # Strip version suffix if provided (e.g., "jack_success.v1" -> "jack_success") + base_prompt_id = get_base_prompt_id(prompt_id=prompt_id) + + # Get all prompts and filter by base_prompt_id + all_prompts = list(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.values()) + prompt_versions = [ + prompt for prompt in all_prompts + if get_base_prompt_id(prompt_id=prompt.prompt_id) == base_prompt_id + ] + + if not prompt_versions: + raise HTTPException( + status_code=404, detail=f"No versions found for prompt ID {base_prompt_id}" + ) + + # Sort by version number (descending - newest first) + prompt_versions.sort(key=lambda p: get_version_number(prompt_id=p.prompt_id), reverse=True) + + return ListPromptsResponse(prompts=prompt_versions) + + @router.get( "/prompts/{prompt_id}", tags=["Prompt Management"], @@ -427,19 +574,21 @@ async def update_prompt( ) try: + # Strip version suffix from prompt_id if present (e.g., "jack_success.v1" -> "jack_success") + base_prompt_id = get_base_prompt_id(prompt_id=prompt_id) + # Check if any version exists existing_prompts = await prisma_client.db.litellm_prompttable.find_many( - where={"prompt_id": request.prompt_id} + where={"prompt_id": base_prompt_id} ) if not existing_prompts: raise HTTPException( - status_code=404, detail=f"Prompt with ID {request.prompt_id} not found" + status_code=404, detail=f"Prompt with ID {base_prompt_id} not found" ) # Check if it's a config prompt - base_prompt_id = request.prompt_id - existing_in_memory = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(base_prompt_id) + existing_in_memory = IN_MEMORY_PROMPT_REGISTRY.get_prompt_by_id(prompt_id) if existing_in_memory and existing_in_memory.prompt_info.prompt_type == "config": raise HTTPException( status_code=400, @@ -448,13 +597,13 @@ async def update_prompt( # Get next version number (UPDATE creates a new version) new_version = await get_next_version_for_prompt( - prisma_client=prisma_client, prompt_id=request.prompt_id + prisma_client=prisma_client, prompt_id=base_prompt_id ) # Store new version in db prompt_db_entry = await prisma_client.db.litellm_prompttable.create( data={ - "prompt_id": request.prompt_id, + "prompt_id": base_prompt_id, "version": new_version, "litellm_params": request.litellm_params.model_dump_json(), "prompt_info": ( diff --git a/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py b/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py new file mode 100644 index 00000000000..bf296f2a03d --- /dev/null +++ b/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py @@ -0,0 +1,222 @@ +""" +Test prompt endpoints for version filtering and history +""" + +from unittest.mock import MagicMock + +import pytest + +from litellm.types.prompts.init_prompts import ( + PromptInfo, + PromptLiteLLMParams, + PromptSpec, +) + + +class TestPromptVersioning: + """ + Test prompt versioning functionality + """ + + def test_get_latest_prompt_versions(self): + """ + Test that get_latest_prompt_versions returns only the latest version of each prompt + """ + from litellm.proxy.prompts.prompt_endpoints import get_latest_prompt_versions + + # Create mock prompts with different versions + prompts = [ + PromptSpec( + prompt_id="jack.v1", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v1 content" + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + PromptSpec( + prompt_id="jack.v2", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v2 content" + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + PromptSpec( + prompt_id="jane.v1", + litellm_params=PromptLiteLLMParams( + prompt_id="jane", + prompt_integration="dotprompt", + dotprompt_content="jane v1" + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + PromptSpec( + prompt_id="jack.v3", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v3 content" + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + ] + + # Get latest versions + latest = get_latest_prompt_versions(prompts=prompts) + + # Should return 2 prompts (jack.v3 and jane.v1) + assert len(latest) == 2 + + # Find jack and jane in results + jack_prompt = next((p for p in latest if "jack" in p.prompt_id), None) + jane_prompt = next((p for p in latest if "jane" in p.prompt_id), None) + + assert jack_prompt is not None + assert jack_prompt.prompt_id == "jack.v3" + assert jack_prompt.litellm_params.dotprompt_content == "v3 content" + + assert jane_prompt is not None + assert jane_prompt.prompt_id == "jane.v1" + + def test_get_version_number(self): + """ + Test that get_version_number correctly extracts version numbers + """ + from litellm.proxy.prompts.prompt_endpoints import get_version_number + + assert get_version_number(prompt_id="jack.v1") == 1 + assert get_version_number(prompt_id="jack.v2") == 2 + assert get_version_number(prompt_id="jack.v10") == 10 + assert get_version_number(prompt_id="jack") == 1 + assert get_version_number(prompt_id="jack.vinvalid") == 1 + + def test_get_base_prompt_id(self): + """ + Test that get_base_prompt_id correctly strips version suffixes + """ + from litellm.proxy.prompts.prompt_endpoints import get_base_prompt_id + + assert get_base_prompt_id(prompt_id="jack.v1") == "jack" + assert get_base_prompt_id(prompt_id="jack.v2") == "jack" + assert get_base_prompt_id(prompt_id="jack") == "jack" + assert get_base_prompt_id(prompt_id="my_prompt.v10") == "my_prompt" + + +class TestPromptVersionsEndpoint: + """ + Test the /prompts/{prompt_id}/versions endpoint + """ + + @pytest.mark.asyncio + async def test_get_prompt_versions_returns_all_versions(self): + """ + Test that get_prompt_versions returns all versions of a prompt sorted by version number + """ + from unittest.mock import MagicMock, patch + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.prompts.prompt_endpoints import get_prompt_versions + + # Mock user with admin role + mock_user = UserAPIKeyAuth( + api_key="test_key", + user_role=LitellmUserRoles.PROXY_ADMIN + ) + + # Create mock prompt registry with multiple versions + mock_prompts = { + "jack.v1": PromptSpec( + prompt_id="jack.v1", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v1" + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + "jack.v2": PromptSpec( + prompt_id="jack.v2", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v2" + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + "jack.v3": PromptSpec( + prompt_id="jack.v3", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v3" + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + "jane.v1": PromptSpec( + prompt_id="jane.v1", + litellm_params=PromptLiteLLMParams( + prompt_id="jane", + prompt_integration="dotprompt", + dotprompt_content="jane" + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + } + + # Mock the IN_MEMORY_PROMPT_REGISTRY at the import location + with patch("litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY") as mock_registry: + mock_registry.IN_MEMORY_PROMPTS = mock_prompts + + # Test with base prompt ID + response = await get_prompt_versions( + prompt_id="jack", + user_api_key_dict=mock_user + ) + + # Should return 3 versions of jack, sorted newest first + assert len(response.prompts) == 3 + assert response.prompts[0].prompt_id == "jack.v3" + assert response.prompts[1].prompt_id == "jack.v2" + assert response.prompts[2].prompt_id == "jack.v1" + + # Test with versioned prompt ID (should strip version) + response = await get_prompt_versions( + prompt_id="jack.v1", + user_api_key_dict=mock_user + ) + + assert len(response.prompts) == 3 + assert response.prompts[0].prompt_id == "jack.v3" + + @pytest.mark.asyncio + async def test_get_prompt_versions_not_found(self): + """ + Test that get_prompt_versions raises 404 when prompt doesn't exist + """ + from unittest.mock import patch + + from fastapi import HTTPException + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.prompts.prompt_endpoints import get_prompt_versions + + mock_user = UserAPIKeyAuth( + api_key="test_key", + user_role=LitellmUserRoles.PROXY_ADMIN + ) + + with patch("litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY") as mock_registry: + mock_registry.IN_MEMORY_PROMPTS = {} + + with pytest.raises(HTTPException) as exc_info: + await get_prompt_versions( + prompt_id="nonexistent", + user_api_key_dict=mock_user + ) + + assert exc_info.value.status_code == 404 + assert "No versions found" in exc_info.value.detail + diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 065d665e134..b3a9c9dc50d 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5239,6 +5239,35 @@ export const getPromptInfo = async (accessToken: string, promptId: string): Prom } }; +export const getPromptVersions = async (accessToken: string, promptId: string): Promise => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/prompts/${promptId}/versions` : `/prompts/${promptId}/versions`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + // Don't throw global error for 404 (no versions found) as we might want to handle it gracefully + if (response.status !== 404) { + handleError(errorMessage); + } + throw new Error(errorMessage); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Failed to get prompt versions:", error); + throw error; + } +}; + export const createPromptCall = async (accessToken: string, promptData: any) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/prompts` : `/prompts`; diff --git a/ui/litellm-dashboard/src/components/prompts.tsx b/ui/litellm-dashboard/src/components/prompts.tsx index a8cdb923f32..24df7e78ab0 100644 --- a/ui/litellm-dashboard/src/components/prompts.tsx +++ b/ui/litellm-dashboard/src/components/prompts.tsx @@ -21,6 +21,7 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { const [selectedPromptId, setSelectedPromptId] = useState(null); const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [showEditorView, setShowEditorView] = useState(false); + const [editPromptData, setEditPromptData] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [promptToDelete, setPromptToDelete] = useState<{ id: string; name: string } | null>(null); @@ -55,6 +56,12 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { if (selectedPromptId) { setSelectedPromptId(null); } + setEditPromptData(null); + setShowEditorView(true); + }; + + const handleEditPrompt = (promptData: any) => { + setEditPromptData(promptData); setShowEditorView(true); }; @@ -71,10 +78,14 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { const handleCloseEditor = () => { setShowEditorView(false); + setEditPromptData(null); }; const handleSuccess = () => { fetchPrompts(); + setShowEditorView(false); + setEditPromptData(null); + setSelectedPromptId(null); }; const handleDeleteClick = (promptId: string, promptName: string) => { @@ -109,6 +120,7 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { onClose={handleCloseEditor} onSuccess={handleSuccess} accessToken={accessToken} + initialPromptData={editPromptData} /> ) : selectedPromptId ? ( = ({ accessToken, userRole }) => { accessToken={accessToken} isAdmin={isAdmin} onDelete={fetchPrompts} + onEdit={handleEditPrompt} /> ) : ( <> diff --git a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/PromptEditorHeader.tsx b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/PromptEditorHeader.tsx index 5339e437a0e..4867cfaec06 100644 --- a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/PromptEditorHeader.tsx +++ b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/PromptEditorHeader.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Button as TremorButton } from "@tremor/react"; import { Input } from "antd"; -import { ArrowLeftIcon, SaveIcon } from "lucide-react"; +import { ArrowLeftIcon, SaveIcon, ClockIcon } from "lucide-react"; interface PromptEditorHeaderProps { promptName: string; @@ -9,6 +9,9 @@ interface PromptEditorHeaderProps { onBack: () => void; onSave: () => void; isSaving: boolean; + editMode?: boolean; + onShowHistory?: () => void; + version?: string | null; } const PromptEditorHeader: React.FC = ({ @@ -17,6 +20,9 @@ const PromptEditorHeader: React.FC = ({ onBack, onSave, isSaving, + editMode = false, + onShowHistory, + version, }) => { return (
@@ -30,17 +36,31 @@ const PromptEditorHeader: React.FC = ({ className="text-base font-medium border-none shadow-none" style={{ width: "200px" }} /> + {version && ( + + {version} + + )} Draft Unsaved changes
+ {editMode && onShowHistory && ( + + History + + )} - Save + {editMode ? "Update" : "Save"}
diff --git a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/VersionHistorySidePanel.tsx b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/VersionHistorySidePanel.tsx new file mode 100644 index 00000000000..9e0fe07c64e --- /dev/null +++ b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/VersionHistorySidePanel.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from "react"; +import { Drawer, List, Tag, Typography, Skeleton, Button } from "antd"; +import { getPromptVersions, PromptSpec } from "../../networking"; +import NotificationsManager from "../../molecules/notifications_manager"; + +const { Text } = Typography; + +interface VersionHistorySidePanelProps { + isOpen: boolean; + onClose: () => void; + accessToken: string | null; + promptId: string; + activeVersionId?: string; + onSelectVersion?: (version: PromptSpec) => void; +} + +const VersionHistorySidePanel: React.FC = ({ + isOpen, + onClose, + accessToken, + promptId, + activeVersionId, + onSelectVersion, +}) => { + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (isOpen && accessToken && promptId) { + fetchVersions(); + } + }, [isOpen, accessToken, promptId]); + + const fetchVersions = async () => { + setLoading(true); + try { + // Strip .v suffix if present to get base ID for querying all versions + const basePromptId = promptId.includes(".v") ? promptId.split(".v")[0] : promptId; + const response = await getPromptVersions(accessToken!, basePromptId); + setVersions(response.prompts); + } catch (error) { + console.error("Error fetching prompt versions:", error); + } finally { + setLoading(false); + } + }; + + const getVersionNumber = (pid: string) => { + if (pid.includes(".v")) { + return `v${pid.split(".v")[1]}`; + } + return "v1"; + }; + + const formatDate = (dateString?: string) => { + if (!dateString) return "-"; + return new Date(dateString).toLocaleString(); + }; + + return ( + + {loading ? ( + + ) : versions.length === 0 ? ( +
+ No version history available. +
+ ) : ( + { + const isSelected = item.prompt_id === (activeVersionId || promptId); + return ( +
onSelectVersion?.(item)} + > +
+
+ + {getVersionNumber(item.prompt_id)} + + {index === 0 && Latest} +
+ {isSelected && ( + + Active + + )} +
+ +
+ + {formatDate(item.created_at)} + + + {item.prompt_info?.prompt_type === "db" ? "Saved to Database" : "Config Prompt"} + +
+
+ ); + }} + /> + )} +
+ ); +}; + +export default VersionHistorySidePanel; + diff --git a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/index.tsx b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/index.tsx index fd59c7bc3fb..f9e76da6ecf 100644 --- a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/index.tsx +++ b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/index.tsx @@ -1,9 +1,9 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import ToolModal from "../tool_modal"; import NotificationsManager from "../../molecules/notifications_manager"; -import { createPromptCall } from "../../networking"; +import { createPromptCall, updatePromptCall } from "../../networking"; import { PromptType, PromptEditorViewProps, Tool } from "./types"; -import { convertToDotPrompt } from "./utils"; +import { convertToDotPrompt, parseExistingPrompt } from "./utils"; import PromptEditorHeader from "./PromptEditorHeader"; import ModelConfigCard from "./ModelConfigCard"; import ToolsCard from "./ToolsCard"; @@ -12,9 +12,19 @@ import PromptMessagesCard from "./PromptMessagesCard"; import ConversationPanel from "./conversation_panel"; import PublishModal from "./PublishModal"; import DotpromptViewTab from "./DotpromptViewTab"; +import VersionHistorySidePanel from "./VersionHistorySidePanel"; -const PromptEditorView: React.FC = ({ onClose, onSuccess, accessToken }) => { - const [prompt, setPrompt] = useState({ +const PromptEditorView: React.FC = ({ onClose, onSuccess, accessToken, initialPromptData }) => { + const getInitialPrompt = (): PromptType => { + if (initialPromptData) { + try { + return parseExistingPrompt(initialPromptData); + } catch (error) { + console.error("Error parsing existing prompt:", error); + NotificationsManager.fromBackend("Failed to parse prompt data"); + } + } + return { name: "New prompt", model: "gpt-4o", config: { @@ -29,7 +39,15 @@ const PromptEditorView: React.FC = ({ onClose, onSuccess, content: "Enter task specifics. Use {{template_variables}} for dynamic inputs", }, ], - }); + }; + }; + + const [prompt, setPrompt] = useState(getInitialPrompt()); + const [editMode, setEditMode] = useState(!!initialPromptData); + const [showHistoryModal, setShowHistoryModal] = useState(false); + const [activeVersionId, setActiveVersionId] = useState( + initialPromptData?.prompt_spec?.prompt_id + ); const [showToolModal, setShowToolModal] = useState(false); const [showNameModal, setShowNameModal] = useState(false); @@ -124,6 +142,18 @@ const PromptEditorView: React.FC = ({ onClose, onSuccess, setShowToolModal(true); }; + const handleLoadVersion = (versionData: any) => { + try { + const loadedPrompt = parseExistingPrompt({ prompt_spec: versionData }); + setPrompt(loadedPrompt); + setActiveVersionId(versionData.prompt_id); + // NotificationsManager.success(`Loaded version ${versionData.prompt_id}`); + } catch (error) { + console.error("Error loading version:", error); + NotificationsManager.fromBackend("Failed to load prompt version"); + } + }; + const handleSaveClick = () => { if (!prompt.name || prompt.name.trim() === "" || prompt.name === "New prompt") { setShowNameModal(true); @@ -160,19 +190,34 @@ const PromptEditorView: React.FC = ({ onClose, onSuccess, }, }; + if (editMode && initialPromptData?.prompt_spec?.prompt_id) { + await updatePromptCall(accessToken, initialPromptData.prompt_spec.prompt_id, promptData); + NotificationsManager.success("Prompt updated successfully!"); + } else { await createPromptCall(accessToken, promptData); NotificationsManager.success("Prompt created successfully!"); + } onSuccess(); onClose(); } catch (error) { console.error("Error saving prompt:", error); - NotificationsManager.fromBackend("Failed to save prompt"); + NotificationsManager.fromBackend(editMode ? "Failed to update prompt" : "Failed to save prompt"); } finally { setIsSaving(false); setShowNameModal(false); } }; + const getVersionNumber = (pid?: string) => { + if (!pid) return null; + if (pid.includes(".v")) { + return `v${pid.split(".v")[1]}`; + } + return null; + }; + + const currentVersion = getVersionNumber(activeVersionId); + return (
@@ -182,6 +227,9 @@ const PromptEditorView: React.FC = ({ onClose, onSuccess, onBack={onClose} onSave={handleSaveClick} isSaving={isSaving} + editMode={editMode} + onShowHistory={() => setShowHistoryModal(true)} + version={currentVersion} />
@@ -284,6 +332,15 @@ const PromptEditorView: React.FC = ({ onClose, onSuccess, }} /> )} + + setShowHistoryModal(false)} + accessToken={accessToken} + promptId={initialPromptData?.prompt_spec?.prompt_id || prompt.name} + activeVersionId={activeVersionId} + onSelectVersion={handleLoadVersion} + />
); }; diff --git a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/types.ts b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/types.ts index 36376e00554..4953c9b9437 100644 --- a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/types.ts +++ b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/types.ts @@ -26,5 +26,6 @@ export interface PromptEditorViewProps { onClose: () => void; onSuccess: () => void; accessToken: string | null; + initialPromptData?: any; } diff --git a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/utils.ts b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/utils.ts index 6da9c44d863..aaae7cccee8 100644 --- a/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/utils.ts +++ b/ui/litellm-dashboard/src/components/prompts/prompt_editor_view/utils.ts @@ -1,4 +1,4 @@ -import { PromptType } from "./types"; +import { PromptType, Message, Tool } from "./types"; export const extractVariables = (prompt: PromptType): string[] => { const variableSet = new Set(); @@ -74,3 +74,88 @@ export const convertToDotPrompt = (prompt: PromptType): string => { return result.trim(); }; +export const parseExistingPrompt = (apiResponse: any): PromptType => { + // Extract dotprompt_content from litellm_params + const dotpromptContent = apiResponse?.prompt_spec?.litellm_params?.dotprompt_content || ""; + + if (!dotpromptContent) { + throw new Error("No dotprompt_content found in API response"); + } + + // Split into frontmatter and content + const parts = dotpromptContent.split("---"); + if (parts.length < 3) { + throw new Error("Invalid dotprompt format"); + } + + // Parse YAML frontmatter (parts[1]) + const frontmatter = parts[1]; + const content = parts.slice(2).join("---").trim(); + + // Extract metadata from frontmatter + const metadata: any = {}; + frontmatter.split("\n").forEach((line: string) => { + const trimmedLine = line.trim(); + if (trimmedLine && !trimmedLine.startsWith("input:") && !trimmedLine.startsWith("output:") && !trimmedLine.startsWith("schema:") && !trimmedLine.startsWith("format:")) { + const colonIndex = trimmedLine.indexOf(":"); + if (colonIndex > 0) { + const key = trimmedLine.substring(0, colonIndex).trim(); + const value = trimmedLine.substring(colonIndex + 1).trim(); + if (key === "temperature" || key === "max_tokens" || key === "top_p") { + metadata[key] = parseFloat(value); + } else if (key === "model") { + metadata[key] = value; + } + } + } + }); + + // Parse content to extract developer message and user messages + let developerMessage = ""; + const messages: Message[] = []; + const lines = content.split("\n"); + let currentRole: "user" | "assistant" | null = null; + let currentContent = ""; + + for (const line of lines) { + if (line.startsWith("Developer:")) { + developerMessage = line.substring("Developer:".length).trim(); + } else if (line.startsWith("User:")) { + if (currentRole && currentContent) { + messages.push({ role: currentRole, content: currentContent.trim() }); + } + currentRole = "user"; + currentContent = line.substring("User:".length).trim(); + } else if (line.startsWith("Assistant:")) { + if (currentRole && currentContent) { + messages.push({ role: currentRole, content: currentContent.trim() }); + } + currentRole = "assistant"; + currentContent = line.substring("Assistant:".length).trim(); + } else if (line.trim() && currentRole) { + currentContent += "\n" + line.trim(); + } + } + + // Add the last message + if (currentRole && currentContent) { + messages.push({ role: currentRole, content: currentContent.trim() }); + } + + // Parse tools from frontmatter if present + const tools: Tool[] = []; + // TODO: Add tool parsing if needed + + return { + name: apiResponse?.prompt_spec?.prompt_id || "Unnamed Prompt", + model: metadata.model || "gpt-4o", + config: { + temperature: metadata.temperature, + max_tokens: metadata.max_tokens, + top_p: metadata.top_p, + }, + tools: tools, + developerMessage: developerMessage, + messages: messages.length > 0 ? messages : [{ role: "user", content: "Enter task specifics. Use {{template_variables}} for dynamic inputs" }], + }; +}; diff --git a/ui/litellm-dashboard/src/components/prompts/prompt_info.tsx b/ui/litellm-dashboard/src/components/prompts/prompt_info.tsx index bbc2a4aa352..958df702540 100644 --- a/ui/litellm-dashboard/src/components/prompts/prompt_info.tsx +++ b/ui/litellm-dashboard/src/components/prompts/prompt_info.tsx @@ -13,7 +13,7 @@ import { TabPanels, } from "@tremor/react"; import { Button, Modal } from "antd"; -import { ArrowLeftIcon, TrashIcon } from "@heroicons/react/outline"; +import { ArrowLeftIcon, TrashIcon, PencilIcon } from "@heroicons/react/outline"; import { getPromptInfo, PromptSpec, PromptTemplateBase, deletePromptCall } from "@/components/networking"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import { CheckIcon, CopyIcon } from "lucide-react"; @@ -25,9 +25,10 @@ export interface PromptInfoProps { accessToken: string | null; isAdmin: boolean; onDelete?: () => void; + onEdit?: (promptData: any) => void; } -const PromptInfoView: React.FC = ({ promptId, onClose, accessToken, isAdmin, onDelete }) => { +const PromptInfoView: React.FC = ({ promptId, onClose, accessToken, isAdmin, onDelete, onEdit }) => { const [promptData, setPromptData] = useState(null); const [promptTemplate, setPromptTemplate] = useState(null); const [rawApiResponse, setRawApiResponse] = useState(null); @@ -131,6 +132,15 @@ const PromptInfoView: React.FC = ({ promptId, onClose, accessTo />
+
+ onEdit?.(rawApiResponse)} + className="flex items-center" + > + Edit Prompt + {isAdmin && ( = ({ promptId, onClose, accessTo Delete Prompt )} +