litellm/tests/test_litellm/integrations/dotprompt/test_prompt_manager.py
mateo-berri 6918214266 Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_fix_dotprompt_model_swap
# Conflicts:
#	tests/test_litellm/integrations/dotprompt/test_prompt_manager.py
2026-08-26 15:13:57 -07:00

748 lines
25 KiB
Python

import json
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, Mock, patch
import httpx
import litellm
from litellm.integrations.dotprompt.dotprompt_manager import DotpromptManager
from litellm.integrations.dotprompt.prompt_manager import PromptManager, PromptTemplate
from litellm.types.prompts.init_prompts import PromptLiteLLMParams, PromptSpec
def test_prompt_manager_initialization():
"""Test basic PromptManager initialization and loading."""
# Test with the existing prompts directory
prompt_dir = Path(
__file__
).parent # Current directory when running from tests/test_litellm/prompts
manager = PromptManager(prompt_directory=str(prompt_dir))
# Should have loaded at least the sample prompts
assert len(manager.prompts) >= 3
assert "sample_prompt" in manager.prompts
assert "chat_prompt" in manager.prompts
assert "coding_assistant" in manager.prompts
def test_prompt_template_creation():
"""Test PromptTemplate creation and metadata extraction."""
metadata = {
"model": "gpt-4",
"temperature": 0.7,
"input": {"schema": {"text": "string"}},
"output": {"format": "json"},
}
template = PromptTemplate(
content="Hello {{name}}!", metadata=metadata, template_id="test_template"
)
assert template.content == "Hello {{name}}!"
assert template.model == "gpt-4"
assert template.optional_params["temperature"] == 0.7
assert template.input_schema == {"text": "string"}
assert template.output_format == "json"
def test_render_simple_template():
"""Test rendering a simple template with variables."""
prompt_dir = Path(
__file__
).parent # Current directory when running from tests/test_litellm/prompts
manager = PromptManager(prompt_directory=str(prompt_dir))
# Test sample_prompt rendering
rendered = manager.render(
"sample_prompt", {"text": "This is a test article about AI."}
)
expected_content = "Extract the requested information from the given text. If a piece of information is not present, omit that field from the output.\n\nText: This is a test article about AI."
assert rendered == expected_content
def test_render_chat_prompt():
"""Test rendering the chat prompt with conditional content."""
prompt_dir = Path(
__file__
).parent # Current directory when running from tests/test_litellm/prompts
manager = PromptManager(prompt_directory=str(prompt_dir))
# Test with system context
rendered = manager.render(
"chat_prompt",
{
"user_message": "Hello there!",
"system_context": "You are a helpful assistant.",
},
)
assert "System: You are a helpful assistant." in rendered
assert "User: Hello there!" in rendered
# Test without system context
rendered_no_system = manager.render("chat_prompt", {"user_message": "Hello there!"})
assert "System:" not in rendered_no_system
assert "User: Hello there!" in rendered_no_system
def test_render_coding_assistant():
"""Test rendering the coding assistant prompt with complex logic."""
prompt_dir = Path(
__file__
).parent # Current directory when running from tests/test_litellm/prompts
manager = PromptManager(prompt_directory=str(prompt_dir))
rendered = manager.render(
"coding_assistant",
{
"language": "Python",
"task": "Create a function to calculate fibonacci numbers",
"code": "def fib(n):\n pass",
"requirements": ["Use recursion", "Handle edge cases", "Add documentation"],
},
)
assert "Focus on Python programming." in rendered
assert "Create a function to calculate fibonacci numbers" in rendered
assert "def fib(n):" in rendered
assert "Use recursion" in rendered
assert "Handle edge cases" in rendered
assert "Add documentation" in rendered
def test_input_validation():
"""Test input validation against schema."""
# Create a temporary directory with a test prompt
with tempfile.TemporaryDirectory() as temp_dir:
prompt_file = Path(temp_dir) / "test_validation.prompt"
prompt_file.write_text(
"""---
input:
schema:
name: string
age: integer
active: boolean
---
Hello {{name}}, you are {{age}} years old and {'active' if active else 'inactive'}."""
)
manager = PromptManager(prompt_directory=str(temp_dir))
# Valid input should work
rendered = manager.render(
"test_validation", {"name": "Alice", "age": 30, "active": True}
)
assert "Hello Alice, you are 30 years old" in rendered
# Invalid type should raise error
with pytest.raises(ValueError, match="Invalid type for field 'age'"):
manager.render(
"test_validation",
{
"name": "Alice",
"age": "thirty", # string instead of int
"active": True,
},
)
def test_prompt_not_found():
"""Test error handling for non-existent prompts."""
prompt_dir = Path(
__file__
).parent # Current directory when running from tests/test_litellm/prompts
manager = PromptManager(prompt_directory=str(prompt_dir))
with pytest.raises(KeyError, match="Prompt 'nonexistent' not found"):
manager.render("nonexistent", {"some": "variable"})
def test_list_prompts():
"""Test listing available prompts."""
prompt_dir = Path(
__file__
).parent # Current directory when running from tests/test_litellm/prompts
manager = PromptManager(prompt_directory=str(prompt_dir))
prompts = manager.list_prompts()
assert isinstance(prompts, list)
assert "sample_prompt" in prompts
assert "chat_prompt" in prompts
assert "coding_assistant" in prompts
def test_get_prompt_metadata():
"""Test retrieving prompt metadata."""
prompt_dir = Path(
__file__
).parent # Current directory when running from tests/test_litellm/prompts
manager = PromptManager(prompt_directory=str(prompt_dir))
metadata = manager.get_prompt_metadata("sample_prompt")
assert metadata is not None
assert metadata["model"] == "gemini/gemini-1.5-pro"
assert "input" in metadata
assert "output" in metadata
def test_get_prompt_with_version():
"""Test that get_prompt correctly retrieves versioned prompts."""
prompt_dir = Path(__file__).parent
manager = PromptManager(prompt_directory=str(prompt_dir))
# Get base prompt (no version)
base_prompt = manager.get_prompt(prompt_id="chat_prompt")
assert base_prompt is not None
assert "User: {{user_message}}" in base_prompt.content
# Get version 1
v1_prompt = manager.get_prompt(prompt_id="chat_prompt", version=1)
assert v1_prompt is not None
assert "Version 1:" in v1_prompt.content
assert v1_prompt.model == "gpt-3.5-turbo"
# Get version 2
v2_prompt = manager.get_prompt(prompt_id="chat_prompt", version=2)
assert v2_prompt is not None
assert "Version 2:" in v2_prompt.content
assert v2_prompt.model == "gpt-4"
def test_add_prompt_programmatically():
"""Test adding prompts programmatically."""
prompt_dir = Path(
__file__
).parent # Current directory when running from tests/test_litellm/prompts
manager = PromptManager(prompt_directory=str(prompt_dir))
initial_count = len(manager.prompts)
manager.add_prompt(
"dynamic_prompt",
"Hello {{name}}! Welcome to {{place}}.",
{"model": "gpt-3.5-turbo", "temperature": 0.5},
)
assert len(manager.prompts) == initial_count + 1
assert "dynamic_prompt" in manager.prompts
rendered = manager.render("dynamic_prompt", {"name": "World", "place": "Earth"})
assert rendered == "Hello World! Welcome to Earth."
def test_frontmatter_parsing():
"""Test YAML frontmatter parsing."""
# Create a temporary directory with a test prompt
with tempfile.TemporaryDirectory() as temp_dir:
# Test with frontmatter
prompt_with_frontmatter = Path(temp_dir) / "with_frontmatter.prompt"
prompt_with_frontmatter.write_text(
"""---
model: gpt-4
temperature: 0.8
input:
schema:
topic: string
---
Write about {{topic}}."""
)
# Test without frontmatter
prompt_without_frontmatter = Path(temp_dir) / "without_frontmatter.prompt"
prompt_without_frontmatter.write_text("Simple template: {{message}}")
manager = PromptManager(prompt_directory=str(temp_dir))
# Check frontmatter was parsed correctly
with_meta = manager.get_prompt("with_frontmatter")
assert with_meta is not None
assert with_meta.model == "gpt-4"
assert with_meta.optional_params["temperature"] == 0.8
# Check template without frontmatter still works
without_meta = manager.get_prompt("without_frontmatter")
assert without_meta is not None
assert without_meta.metadata == {}
rendered = manager.render("without_frontmatter", {"message": "Hello!"})
assert rendered == "Simple template: Hello!"
def test_prompt_manager_json_initialization():
"""Test PromptManager initialization with JSON data instead of directory."""
prompt_data = {
"json_test_prompt": {
"content": "Hello {{name}}! Welcome to {{service}}.",
"metadata": {"model": "gpt-4", "temperature": 0.8, "max_tokens": 150},
},
"simple_prompt": {
"content": "This is a simple prompt: {{message}}",
"metadata": {},
},
}
# Initialize PromptManager with JSON data only (no directory)
manager = PromptManager(prompt_data=prompt_data)
# Should have loaded the JSON prompts
assert len(manager.prompts) == 2
assert "json_test_prompt" in manager.prompts
assert "simple_prompt" in manager.prompts
# Test prompt properties
json_prompt = manager.get_prompt("json_test_prompt")
assert json_prompt.content == "Hello {{name}}! Welcome to {{service}}."
assert json_prompt.model == "gpt-4"
assert json_prompt.optional_params["temperature"] == 0.8
assert json_prompt.optional_params["max_tokens"] == 150
def test_prompt_manager_mixed_initialization():
"""Test PromptManager with both directory and JSON data."""
# Use existing directory
prompt_dir = Path(__file__).parent
# Add JSON data
json_data = {
"json_only_prompt": {
"content": "This prompt only exists in JSON: {{data}}",
"metadata": {"model": "gpt-3.5-turbo"},
}
}
manager = PromptManager(prompt_directory=str(prompt_dir), prompt_data=json_data)
# Should have prompts from both directory and JSON
assert "sample_prompt" in manager.prompts # From directory
assert "json_only_prompt" in manager.prompts # From JSON
# Test rendering both types
json_rendered = manager.render("json_only_prompt", {"data": "test"})
assert json_rendered == "This prompt only exists in JSON: test"
def test_load_prompts_from_json_data():
"""Test loading additional prompts from JSON data after initialization."""
# Start with directory-based manager
prompt_dir = Path(__file__).parent
manager = PromptManager(prompt_directory=str(prompt_dir))
initial_count = len(manager.prompts)
# Load additional prompts from JSON
additional_prompts = {
"dynamic_json_prompt": {
"content": "Dynamic prompt: {{dynamic_content}}",
"metadata": {"model": "claude-3", "temperature": 0.5},
},
"another_json_prompt": {
"content": "Another prompt with {{variable}}",
"metadata": {"model": "gpt-4"},
},
}
manager.load_prompts_from_json_data(additional_prompts)
# Should have added the new prompts
assert len(manager.prompts) == initial_count + 2
assert "dynamic_json_prompt" in manager.prompts
assert "another_json_prompt" in manager.prompts
# Test rendering the new prompts
rendered = manager.render("dynamic_json_prompt", {"dynamic_content": "test"})
assert rendered == "Dynamic prompt: test"
def test_prompt_file_to_json_conversion():
"""Test converting .prompt files to JSON format."""
# Create a temporary prompt file with frontmatter
with tempfile.TemporaryDirectory() as temp_dir:
prompt_file = Path(temp_dir) / "test_conversion.prompt"
prompt_file.write_text(
"""---
model: gpt-4
temperature: 0.7
max_tokens: 200
input:
schema:
user_input: string
context: string
output:
format: json
---
You are an AI assistant. Given the context: {{context}}
Please respond to: {{user_input}}"""
)
manager = PromptManager()
json_data = manager.prompt_file_to_json(prompt_file)
# Check the conversion
assert "content" in json_data
assert "metadata" in json_data
expected_content = """You are an AI assistant. Given the context: {{context}}
Please respond to: {{user_input}}"""
assert json_data["content"] == expected_content
metadata = json_data["metadata"]
assert metadata["model"] == "gpt-4"
assert metadata["temperature"] == 0.7
assert metadata["max_tokens"] == 200
assert metadata["input"]["schema"]["user_input"] == "string"
assert metadata["output"]["format"] == "json"
def test_json_to_prompt_file_conversion():
"""Test converting JSON data back to .prompt file format."""
json_data = {
"content": "Hello {{name}}! How can I help you with {{task}}?",
"metadata": {
"model": "gpt-3.5-turbo",
"temperature": 0.8,
"max_tokens": 100,
"input": {"schema": {"name": "string", "task": "string"}},
},
}
manager = PromptManager()
prompt_content = manager.json_to_prompt_file(json_data)
# Should have YAML frontmatter and content
assert prompt_content.startswith("---\n")
assert "---\n" in prompt_content[4:] # Second --- delimiter
assert "Hello {{name}}! How can I help you with {{task}}?" in prompt_content
assert "model: gpt-3.5-turbo" in prompt_content
assert "temperature: 0.8" in prompt_content
def test_json_to_prompt_file_without_metadata():
"""Test converting JSON with no metadata to .prompt format."""
json_data = {
"content": "Simple prompt without metadata: {{message}}",
"metadata": {},
}
manager = PromptManager()
prompt_content = manager.json_to_prompt_file(json_data)
# Should return just the content without frontmatter
assert prompt_content == "Simple prompt without metadata: {{message}}"
assert "---" not in prompt_content
def test_get_all_prompts_as_json():
"""Test exporting all prompts to JSON format."""
prompt_data = {
"prompt1": {
"content": "First prompt: {{var1}}",
"metadata": {"model": "gpt-4"},
},
"prompt2": {
"content": "Second prompt: {{var2}}",
"metadata": {"model": "claude-3", "temperature": 0.5},
},
}
manager = PromptManager(prompt_data=prompt_data)
all_prompts_json = manager.get_all_prompts_as_json()
assert len(all_prompts_json) == 2
assert "prompt1" in all_prompts_json
assert "prompt2" in all_prompts_json
# Check structure
prompt1_data = all_prompts_json["prompt1"]
assert prompt1_data["content"] == "First prompt: {{var1}}"
assert prompt1_data["metadata"]["model"] == "gpt-4"
prompt2_data = all_prompts_json["prompt2"]
assert prompt2_data["content"] == "Second prompt: {{var2}}"
assert prompt2_data["metadata"]["model"] == "claude-3"
def test_json_prompt_rendering_with_validation():
"""Test rendering JSON-based prompts with input validation."""
prompt_data = {
"validated_prompt": {
"content": "Process {{data}} for user {{user_id}}",
"metadata": {
"model": "gpt-4",
"input": {"schema": {"data": "string", "user_id": "integer"}},
},
}
}
manager = PromptManager(prompt_data=prompt_data)
# Valid input should work
rendered = manager.render("validated_prompt", {"data": "test data", "user_id": 123})
assert rendered == "Process test data for user 123"
# Invalid input should raise error
with pytest.raises(ValueError, match="Invalid type for field 'user_id'"):
manager.render(
"validated_prompt", {"data": "test data", "user_id": "not_an_int"}
)
def test_round_trip_conversion():
"""Test converting .prompt file to JSON and back to .prompt file."""
with tempfile.TemporaryDirectory() as temp_dir:
# Create original prompt file
original_file = Path(temp_dir) / "original.prompt"
original_content = """---
model: gpt-4
temperature: 0.6
---
Original prompt content: {{variable}}"""
original_file.write_text(original_content)
manager = PromptManager()
# Convert to JSON
json_data = manager.prompt_file_to_json(original_file)
# Convert back to prompt file format
converted_content = manager.json_to_prompt_file(json_data)
# Create new file with converted content
converted_file = Path(temp_dir) / "converted.prompt"
converted_file.write_text(converted_content)
# Load both files and compare
manager_original = PromptManager(prompt_directory=str(temp_dir))
original_template = manager_original.get_prompt("original")
converted_template = manager_original.get_prompt("converted")
# Content should be the same
assert original_template.content == converted_template.content
assert original_template.model == converted_template.model
assert (
original_template.optional_params["temperature"]
== converted_template.optional_params["temperature"]
)
def test_prompt_main():
"""
Integration test placeholder for litellm completion integration.
This would be implemented once the PromptManager is integrated with litellm.
"""
# TODO: Implement once PromptManager is integrated with litellm completion
pass
@pytest.mark.asyncio
async def test_dotprompt_with_prompt_version():
"""
Test that dotprompt can load and use specific prompt versions.
Versions are stored as separate files with .v{version}.prompt naming convention.
"""
from litellm.integrations.dotprompt.prompt_manager import PromptManager
prompt_dir = Path(__file__).parent
prompt_manager = PromptManager(prompt_directory=str(prompt_dir))
# Test version 1
v1_prompt = prompt_manager.get_prompt(prompt_id="chat_prompt", version=1)
assert v1_prompt is not None
assert v1_prompt.model == "gpt-3.5-turbo"
# Verify version 1 content
v1_rendered = prompt_manager.render(
prompt_id="chat_prompt", prompt_variables={"user_message": "Test v1"}, version=1
)
assert "Version 1:" in v1_rendered
assert "Test v1" in v1_rendered
# Test version 2
v2_prompt = prompt_manager.get_prompt(prompt_id="chat_prompt", version=2)
assert v2_prompt is not None
assert v2_prompt.model == "gpt-4"
# Verify version 2 content
v2_rendered = prompt_manager.render(
prompt_id="chat_prompt", prompt_variables={"user_message": "Test v2"}, version=2
)
assert "Version 2:" in v2_rendered
assert "Test v2" in v2_rendered
def test_keyed_prompt_data_with_prompt_id_keeps_real_content():
prompt_data = {
"json_prompt": {
"content": "You are a pirate. Begin every reply with AHOY.",
"metadata": {"model": "gpt-4o-mini"},
}
}
manager = PromptManager(prompt_data=prompt_data, prompt_id="agent-prompt")
template = manager.get_prompt("json_prompt")
assert template is not None
assert template.content == "You are a pirate. Begin every reply with AHOY."
assert template.model == "gpt-4o-mini"
assert "agent-prompt" not in manager.prompts
def test_flat_prompt_data_with_prompt_id_registers_under_prompt_id():
manager = PromptManager(
prompt_data={"content": "Hello {{name}}", "metadata": {"model": "gpt-4o-mini"}},
prompt_id="flat-prompt",
)
template = manager.get_prompt("flat-prompt")
assert template is not None
assert template.content == "Hello {{name}}"
assert manager.render("flat-prompt", {"name": "world"}) == "Hello world"
def test_get_prompt_falls_back_to_base_id_for_versioned_id():
manager = PromptManager(
prompt_data={"content": "Hi", "metadata": {}},
prompt_id="my-prompt",
)
assert manager.get_prompt("my-prompt.v1") is not None
assert manager.get_prompt("my-prompt.v12") is not None
assert manager.get_prompt("my-prompt.vx") is None
assert manager.get_prompt("other-prompt.v1") is None
def test_should_run_prompt_management_accepts_versioned_id():
from litellm.integrations.dotprompt import DotpromptManager
dotprompt_manager = DotpromptManager(
prompt_data={"content": "Hi", "metadata": {}},
prompt_id="versioned-prompt",
)
assert dotprompt_manager.should_run_prompt_management("versioned-prompt", None, {}) is True
assert dotprompt_manager.should_run_prompt_management("versioned-prompt.v1", None, {}) is True
assert dotprompt_manager.should_run_prompt_management("missing-prompt", None, {}) is False
def test_prompt_initializer_registers_flat_db_prompt_under_base_id():
from litellm.integrations.dotprompt import DotpromptManager, prompt_initializer
from litellm.types.prompts.init_prompts import (
PromptInfo,
PromptLiteLLMParams,
PromptSpec,
)
litellm_params = PromptLiteLLMParams(
prompt_integration="dotprompt",
prompt_data={"content": "AHOY {{name}}", "metadata": {"model": "gpt-4o-mini"}},
)
prompt_spec = PromptSpec(
prompt_id="agent-prompt.v1",
litellm_params=litellm_params,
prompt_info=PromptInfo(prompt_type="db"),
)
dotprompt_manager = prompt_initializer(litellm_params, prompt_spec)
assert isinstance(dotprompt_manager, DotpromptManager)
template = dotprompt_manager.prompt_manager.get_prompt("agent-prompt")
assert template is not None
assert template.content == "AHOY {{name}}"
def _swap_prompt_manager_and_spec(ignore_prompt_manager_model: bool) -> tuple[DotpromptManager, PromptSpec]:
manager = DotpromptManager(
prompt_data={"content": "You are a pirate assistant.", "metadata": {"model": "gpt-4o-mini"}},
prompt_id="swap-prompt",
)
spec = PromptSpec(
prompt_id="swap-prompt",
litellm_params=PromptLiteLLMParams(
prompt_id="swap-prompt",
prompt_integration="dotprompt",
ignore_prompt_manager_model=ignore_prompt_manager_model,
),
)
return manager, spec
@pytest.mark.asyncio
async def test_async_prompt_spec_ignore_prompt_manager_model_keeps_requested_model():
from litellm.types.utils import StandardCallbackDynamicParams
manager, spec = _swap_prompt_manager_and_spec(ignore_prompt_manager_model=True)
model, messages, _ = await manager.async_get_chat_completion_prompt(
model="anthropic/claude-haiku-4-5",
messages=[{"role": "user", "content": "hi"}],
non_default_params={},
prompt_id="swap-prompt",
prompt_variables=None,
dynamic_callback_params=StandardCallbackDynamicParams(),
litellm_logging_obj=MagicMock(),
prompt_spec=spec,
)
assert model == "anthropic/claude-haiku-4-5"
assert len(messages) == 2
assert "pirate" in str(messages[0]["content"])
@pytest.mark.asyncio
async def test_async_prompt_spec_without_ignore_flag_swaps_model():
from litellm.types.utils import StandardCallbackDynamicParams
manager, spec = _swap_prompt_manager_and_spec(ignore_prompt_manager_model=False)
model, _, _ = await manager.async_get_chat_completion_prompt(
model="anthropic/claude-haiku-4-5",
messages=[{"role": "user", "content": "hi"}],
non_default_params={},
prompt_id="swap-prompt",
prompt_variables=None,
dynamic_callback_params=StandardCallbackDynamicParams(),
litellm_logging_obj=MagicMock(),
prompt_spec=spec,
)
assert model == "gpt-4o-mini"
def test_sync_prompt_spec_ignore_prompt_manager_model_keeps_requested_model():
from litellm.types.utils import StandardCallbackDynamicParams
manager, spec = _swap_prompt_manager_and_spec(ignore_prompt_manager_model=True)
model, _, _ = manager.get_chat_completion_prompt(
model="anthropic/claude-haiku-4-5",
messages=[{"role": "user", "content": "hi"}],
non_default_params={},
prompt_id="swap-prompt",
prompt_variables=None,
dynamic_callback_params=StandardCallbackDynamicParams(),
prompt_spec=spec,
)
assert model == "anthropic/claude-haiku-4-5"
def test_sync_caller_ignore_flag_survives_missing_prompt_spec():
from litellm.types.utils import StandardCallbackDynamicParams
manager, _ = _swap_prompt_manager_and_spec(ignore_prompt_manager_model=False)
model, _, _ = manager.get_chat_completion_prompt(
model="anthropic/claude-haiku-4-5",
messages=[{"role": "user", "content": "hi"}],
non_default_params={},
prompt_id="swap-prompt",
prompt_variables=None,
dynamic_callback_params=StandardCallbackDynamicParams(),
prompt_spec=None,
ignore_prompt_manager_model=True,
)
assert model == "anthropic/claude-haiku-4-5"