chore: import upstream snapshot with attribution
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
import pytest
|
||||
from opik.api_objects.prompt import ChatPromptTemplate, PromptType
|
||||
from opik import exceptions
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__simple_text_message__happyflow():
|
||||
"""Test basic formatting of a simple text message."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hi, my name is {{name}} and I live in {{city}}.",
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.format({"name": "Harry", "city": "London"})
|
||||
|
||||
assert result == [
|
||||
{"role": "user", "content": "Hi, my name is Harry and I live in London."}
|
||||
]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__multiple_messages__happyflow():
|
||||
"""Test formatting multiple messages with different roles."""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant in {{location}}."},
|
||||
{"role": "user", "content": "What is the capital of {{country}}?"},
|
||||
{"role": "assistant", "content": "The capital is {{capital}}."},
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.format(
|
||||
{
|
||||
"location": "London",
|
||||
"country": "France",
|
||||
"capital": "Paris",
|
||||
}
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{"role": "system", "content": "You are a helpful assistant in London."},
|
||||
{"role": "user", "content": "What is the capital of France?"},
|
||||
{"role": "assistant", "content": "The capital is Paris."},
|
||||
]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__multimodal_content__happyflow():
|
||||
"""Test formatting messages with multimodal content (text + image) when vision is supported."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this {{object}}:"},
|
||||
{"type": "image_url", "image_url": {"url": "{{image_url}}"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
# Format with vision supported to get structured content
|
||||
result = tested.format(
|
||||
{"object": "painting", "image_url": "https://example.com/image.jpg"},
|
||||
supported_modalities={"vision": True},
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this painting:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/image.jpg"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__jinja2_template__happyflow():
|
||||
"""Test formatting with Jinja2 template type."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hi, my name is {{ name }} and I live in {{ city }}.",
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages, template_type=PromptType.JINJA2)
|
||||
|
||||
result = tested.format({"name": "Harry", "city": "London"})
|
||||
|
||||
assert result == [
|
||||
{"role": "user", "content": "Hi, my name is Harry and I live in London."}
|
||||
]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__jinja2_with_control_flow():
|
||||
"""Test Jinja2 formatting with control flow."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": """
|
||||
{% if is_wizard %}
|
||||
{{ name }} is a wizard who lives in {{ city }}.
|
||||
{% else %}
|
||||
{{ name }} is a muggle who lives in {{ city }}.
|
||||
{% endif %}
|
||||
""",
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages, template_type=PromptType.JINJA2)
|
||||
|
||||
wizard_result = tested.format(
|
||||
{"name": "Harry", "city": "London", "is_wizard": True}
|
||||
)
|
||||
assert (
|
||||
"Harry is a wizard who lives in London." in wizard_result[0]["content"].strip()
|
||||
)
|
||||
|
||||
muggle_result = tested.format(
|
||||
{"name": "Dudley", "city": "Surrey", "is_wizard": False}
|
||||
)
|
||||
assert (
|
||||
"Dudley is a muggle who lives in Surrey." in muggle_result[0]["content"].strip()
|
||||
)
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__jinja2_with_loops():
|
||||
"""Test Jinja2 formatting with loops."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": """
|
||||
{{ name }}'s friends are:
|
||||
{% for friend in friends %}
|
||||
- {{ friend }}
|
||||
{% endfor %}
|
||||
""",
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages, template_type=PromptType.JINJA2)
|
||||
|
||||
result = tested.format({"name": "Harry", "friends": ["Ron", "Hermione", "Neville"]})
|
||||
|
||||
content = result[0]["content"]
|
||||
assert "Harry's friends are:" in content
|
||||
assert "- Ron" in content
|
||||
assert "- Hermione" in content
|
||||
assert "- Neville" in content
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__empty_content():
|
||||
"""Test formatting with empty content."""
|
||||
messages = [
|
||||
{"role": "user", "content": ""},
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.format({})
|
||||
|
||||
assert result == [{"role": "user", "content": ""}]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__message_without_role__skipped():
|
||||
"""Test that messages without a role are skipped."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello {{name}}"},
|
||||
{"content": "This message has no role"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.format({"name": "Harry"})
|
||||
|
||||
# Only messages with roles should be included
|
||||
assert result == [
|
||||
{"role": "user", "content": "Hello Harry"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
|
||||
def test_chat_prompt_template__required_modalities__text_only():
|
||||
"""Test required_modalities returns empty set for text-only messages."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Simple text message"},
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.required_modalities()
|
||||
|
||||
assert result == set()
|
||||
|
||||
|
||||
def test_chat_prompt_template__required_modalities__with_vision():
|
||||
"""Test required_modalities detects vision modality."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this image:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/img.jpg"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.required_modalities()
|
||||
|
||||
assert "vision" in result
|
||||
|
||||
|
||||
def test_chat_prompt_template__required_modalities__multiple_messages():
|
||||
"""Test required_modalities across multiple messages."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Text only"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "With image:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/img.jpg"},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.required_modalities()
|
||||
|
||||
assert "vision" in result
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__unsupported_modality_replaced_with_placeholder():
|
||||
"""Test that unsupported modalities are replaced with placeholders."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this image:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/img.jpg"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
# Format with vision not supported
|
||||
result = tested.format({}, supported_modalities={"vision": False})
|
||||
|
||||
assert len(result) == 1
|
||||
# When vision is not supported, image should be replaced with placeholder
|
||||
content = result[0]["content"]
|
||||
assert isinstance(content, str)
|
||||
assert "<<<image>>>" in content
|
||||
assert "<<</image>>>" in content
|
||||
assert "Describe this image:" in content
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__supported_modality_preserved():
|
||||
"""Test that supported modalities are preserved as structured content."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this image:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/img.jpg"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
# Format with vision supported
|
||||
result = tested.format({}, supported_modalities={"vision": True})
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this image:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/img.jpg"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__multimodal_with_template_variables():
|
||||
"""Test multimodal content with template variables in both text and image URL."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this {{object}}:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/{{filename}}"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.format(
|
||||
{"object": "diagram", "filename": "diagram.png"},
|
||||
supported_modalities={"vision": True},
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this diagram:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://example.com/diagram.png"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__image_with_detail_parameter():
|
||||
"""Test that image detail parameter is preserved during formatting."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "{{url}}", "detail": "high"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.format(
|
||||
{"url": "https://example.com/img.jpg"}, supported_modalities={"vision": True}
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze:"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "https://example.com/img.jpg",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_chat_prompt_template__messages_property():
|
||||
"""Test that messages property returns the original messages."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello {{name}}"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
assert tested.messages == messages
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__override_template_type():
|
||||
"""Test that template_type can be overridden in format call."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Name: {{name}}, City: {{city}}"},
|
||||
]
|
||||
|
||||
# Create with Mustache default
|
||||
tested = ChatPromptTemplate(messages, template_type=PromptType.MUSTACHE)
|
||||
|
||||
# Override with Jinja2 in format
|
||||
result = tested.format(
|
||||
{"name": "Harry", "city": "London"},
|
||||
template_type=PromptType.JINJA2,
|
||||
)
|
||||
|
||||
assert result == [{"role": "user", "content": "Name: Harry, City: London"}]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__one_placeholder_used_multiple_times():
|
||||
"""Test formatting with the same placeholder used multiple times."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "My name is {{name}}. I repeat, my name is {{name}}.",
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.format({"name": "Harry"})
|
||||
|
||||
assert result == [
|
||||
{"role": "user", "content": "My name is Harry. I repeat, my name is Harry."}
|
||||
]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__empty_messages_list():
|
||||
"""Test formatting with empty messages list."""
|
||||
messages = []
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.format({})
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__message_with_missing_content():
|
||||
"""Test formatting when message has no content field."""
|
||||
messages = [
|
||||
{"role": "user"}, # No content field
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages)
|
||||
|
||||
result = tested.format({})
|
||||
|
||||
assert result == [{"role": "user", "content": ""}]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__passed_arguments_not_in_template__error_raised():
|
||||
"""Test that extra format arguments not in template raise an error."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hi, my name is {{name}}, I live in {{city}}."}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages, validate_placeholders=True)
|
||||
|
||||
with pytest.raises(
|
||||
exceptions.PromptPlaceholdersDontMatchFormatArguments
|
||||
) as exc_info:
|
||||
tested.format({"name": "Harry", "city": "London", "nemesis_name": "Voldemort"})
|
||||
|
||||
assert exc_info.value.format_arguments == set(["name", "city", "nemesis_name"])
|
||||
assert exc_info.value.prompt_placeholders == set(["name", "city"])
|
||||
assert exc_info.value.symmetric_difference == set(["nemesis_name"])
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__some_placeholders_missing__error_raised():
|
||||
"""Test that missing required placeholders raise an error."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hi, my name is {{name}}, I live in {{city}}."}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages, validate_placeholders=True)
|
||||
|
||||
with pytest.raises(
|
||||
exceptions.PromptPlaceholdersDontMatchFormatArguments
|
||||
) as exc_info:
|
||||
tested.format({"name": "Harry"})
|
||||
|
||||
assert exc_info.value.format_arguments == set(["name"])
|
||||
assert exc_info.value.prompt_placeholders == set(["name", "city"])
|
||||
assert exc_info.value.symmetric_difference == set(["city"])
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__placeholders_mismatch_both_ways__error_raised():
|
||||
"""Test error when some placeholders are missing AND extra arguments provided."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hi, my name is {{name}}, I live in {{city}}."}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages, validate_placeholders=True)
|
||||
|
||||
with pytest.raises(
|
||||
exceptions.PromptPlaceholdersDontMatchFormatArguments
|
||||
) as exc_info:
|
||||
tested.format({"name": "Harry", "nemesis_name": "Voldemort"})
|
||||
|
||||
assert exc_info.value.format_arguments == set(["name", "nemesis_name"])
|
||||
assert exc_info.value.prompt_placeholders == set(["name", "city"])
|
||||
assert exc_info.value.symmetric_difference == set(["city", "nemesis_name"])
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__multimodal_placeholders__validates_all():
|
||||
"""Test that placeholders in multimodal content are validated."""
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this {{object}}:"},
|
||||
{"type": "image_url", "image_url": {"url": "{{image_url}}"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages, validate_placeholders=True)
|
||||
|
||||
# Missing image_url placeholder
|
||||
with pytest.raises(
|
||||
exceptions.PromptPlaceholdersDontMatchFormatArguments
|
||||
) as exc_info:
|
||||
tested.format({"object": "painting"})
|
||||
|
||||
assert exc_info.value.format_arguments == set(["object"])
|
||||
assert exc_info.value.prompt_placeholders == set(["object", "image_url"])
|
||||
assert exc_info.value.symmetric_difference == set(["image_url"])
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__multiple_messages_placeholders__validates_all():
|
||||
"""Test that placeholders across multiple messages are validated."""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an assistant in {{location}}."},
|
||||
{"role": "user", "content": "What is the capital of {{country}}?"},
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages, validate_placeholders=True)
|
||||
|
||||
# Missing country placeholder
|
||||
with pytest.raises(
|
||||
exceptions.PromptPlaceholdersDontMatchFormatArguments
|
||||
) as exc_info:
|
||||
tested.format({"location": "London"})
|
||||
|
||||
assert exc_info.value.format_arguments == set(["location"])
|
||||
assert exc_info.value.prompt_placeholders == set(["location", "country"])
|
||||
assert exc_info.value.symmetric_difference == set(["country"])
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__validation_disabled__no_error():
|
||||
"""Test that validation can be disabled."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hi, my name is {{name}}, I live in {{city}}."}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages, validate_placeholders=False)
|
||||
|
||||
# Should not raise an error even though city is missing
|
||||
result = tested.format({"name": "Harry"})
|
||||
|
||||
# The template will leave unformatted placeholders
|
||||
assert result == [
|
||||
{"role": "user", "content": "Hi, my name is Harry, I live in {{city}}."}
|
||||
]
|
||||
|
||||
|
||||
def test_chat_prompt_template__format__jinja2_no_validation():
|
||||
"""Test that Jinja2 templates don't validate placeholders."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hi, my name is {{ name }}, I live in {{ city }}."}
|
||||
]
|
||||
|
||||
tested = ChatPromptTemplate(messages, template_type=PromptType.JINJA2)
|
||||
|
||||
# Jinja2 templates don't validate, so this should not raise an error
|
||||
# Jinja2 will just render missing variables as empty
|
||||
result = tested.format({"name": "Harry"})
|
||||
|
||||
# Should render successfully (Jinja2 handles missing variables gracefully)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
@@ -0,0 +1,59 @@
|
||||
from opik.api_objects.prompt.mask_context import (
|
||||
get_active_prompt_masks,
|
||||
get_mask_for_prompt,
|
||||
prompt_mask_context,
|
||||
)
|
||||
|
||||
|
||||
class TestPromptMaskContext:
|
||||
def test_no_context__returns_none(self):
|
||||
assert get_active_prompt_masks() is None
|
||||
|
||||
def test_inside_context__returns_masks_dict(self):
|
||||
masks = {"prompt-1": "mask-a", "prompt-2": "mask-b"}
|
||||
with prompt_mask_context(masks):
|
||||
assert get_active_prompt_masks() == masks
|
||||
|
||||
def test_get_mask_for_prompt__prompt_in_dict__returns_mask_id(self):
|
||||
masks = {"prompt-uuid": "mask-uuid"}
|
||||
with prompt_mask_context(masks):
|
||||
assert get_mask_for_prompt("prompt-uuid") == "mask-uuid"
|
||||
|
||||
def test_get_mask_for_prompt__prompt_not_in_dict__returns_none(self):
|
||||
masks = {"other-prompt": "mask-abc"}
|
||||
with prompt_mask_context(masks):
|
||||
assert get_mask_for_prompt("unknown-prompt") is None
|
||||
|
||||
def test_get_mask_for_prompt__no_context__returns_none(self):
|
||||
assert get_mask_for_prompt("any-prompt-id") is None
|
||||
|
||||
def test_after_exit__resets_to_none(self):
|
||||
with prompt_mask_context({"prompt-1": "mask-1"}):
|
||||
pass
|
||||
assert get_active_prompt_masks() is None
|
||||
|
||||
def test_context_with_none_masks__returns_none(self):
|
||||
with prompt_mask_context(None):
|
||||
assert get_active_prompt_masks() is None
|
||||
assert get_mask_for_prompt("any-id") is None
|
||||
|
||||
def test_exception_inside__resets(self):
|
||||
try:
|
||||
with prompt_mask_context({"p": "m"}):
|
||||
assert get_active_prompt_masks() == {"p": "m"}
|
||||
raise ValueError("boom")
|
||||
except ValueError:
|
||||
pass
|
||||
assert get_active_prompt_masks() is None
|
||||
|
||||
def test_nested_contexts__inner_wins_then_restores_outer(self):
|
||||
outer = {"p1": "m1"}
|
||||
inner = {"p2": "m2"}
|
||||
with prompt_mask_context(outer):
|
||||
assert get_active_prompt_masks() == outer
|
||||
with prompt_mask_context(inner):
|
||||
assert get_active_prompt_masks() == inner
|
||||
assert get_mask_for_prompt("p2") == "m2"
|
||||
assert get_mask_for_prompt("p1") is None
|
||||
assert get_active_prompt_masks() == outer
|
||||
assert get_active_prompt_masks() is None
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Unit tests for the prompt client-side cache."""
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from opik import config as opik_config
|
||||
from opik.api_objects.prompt import prompt_cache
|
||||
from opik.api_objects.prompt.prompt_cache import (
|
||||
PromptCache,
|
||||
get_global_cache,
|
||||
)
|
||||
|
||||
|
||||
def _get_ttl() -> int:
|
||||
return opik_config.OpikConfig().prompt_cache_ttl_seconds
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_caches_after_test():
|
||||
yield
|
||||
get_global_cache().clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache():
|
||||
c = PromptCache()
|
||||
yield c
|
||||
c.clear()
|
||||
|
||||
|
||||
def _make_mock_prompt(
|
||||
name: str = "my-prompt",
|
||||
commit: str = "abc123",
|
||||
prompt_id: Optional[str] = None,
|
||||
) -> mock.Mock:
|
||||
p = mock.Mock()
|
||||
p.name = name
|
||||
p.commit = commit
|
||||
p.__internal_api__prompt_id__ = prompt_id
|
||||
return p
|
||||
|
||||
|
||||
class TestPromptCache:
|
||||
def test_get__missing_key__returns_none(self, cache):
|
||||
assert cache.get(("p", None, None, "text")) is None
|
||||
|
||||
def test_get_or_fetch__cache_miss__fetches_and_caches(self, cache):
|
||||
p = _make_mock_prompt()
|
||||
fetch_fn = mock.Mock(return_value=p)
|
||||
result = cache.get_or_fetch(
|
||||
("p", None, None, "text"), fetch_fn, ttl_seconds=300
|
||||
)
|
||||
assert result is p
|
||||
fetch_fn.assert_called_once()
|
||||
|
||||
def test_get_or_fetch__cache_hit__does_not_call_fetch_fn(self, cache):
|
||||
p = _make_mock_prompt()
|
||||
fetch_fn = mock.Mock(return_value=p)
|
||||
cache.get_or_fetch(("p", None, None, "text"), fetch_fn, ttl_seconds=300)
|
||||
fetch_fn.reset_mock()
|
||||
result = cache.get_or_fetch(
|
||||
("p", None, None, "text"), fetch_fn, ttl_seconds=300
|
||||
)
|
||||
assert result is p
|
||||
fetch_fn.assert_not_called()
|
||||
|
||||
def test_get_or_fetch__fetch_returns_none__returns_none(self, cache):
|
||||
fetch_fn = mock.Mock(return_value=None)
|
||||
result = cache.get_or_fetch(
|
||||
("missing", None, None, "text"), fetch_fn, ttl_seconds=300
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_clear__populated_cache__removes_all_entries(self, cache):
|
||||
p = _make_mock_prompt()
|
||||
cache.get_or_fetch(
|
||||
("p", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=None
|
||||
)
|
||||
cache.clear()
|
||||
assert cache.get(("p", None, None, "text")) is None
|
||||
|
||||
def test_clear__running_refresh_thread__stops_thread(self, cache):
|
||||
p = _make_mock_prompt()
|
||||
cache.get_or_fetch(
|
||||
("p", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=300
|
||||
)
|
||||
assert cache._thread is not None and cache._thread.is_alive()
|
||||
cache.clear()
|
||||
assert cache._thread is None
|
||||
|
||||
def test_refresh_thread__unpinned_entry__starts_thread(self, cache):
|
||||
p = _make_mock_prompt()
|
||||
cache.get_or_fetch(
|
||||
("p", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=300
|
||||
)
|
||||
assert cache._thread is not None
|
||||
assert cache._thread.is_alive()
|
||||
|
||||
def test_refresh_thread__pinned_entry__does_not_start_thread(self, cache):
|
||||
p = _make_mock_prompt()
|
||||
cache.get_or_fetch(
|
||||
("p", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=None
|
||||
)
|
||||
assert cache._thread is None
|
||||
|
||||
def test_get_or_fetch__different_keys__returns_separate_entries(self, cache):
|
||||
p1 = _make_mock_prompt(commit="c1")
|
||||
p2 = _make_mock_prompt(commit="c2")
|
||||
cache.get_or_fetch(
|
||||
("p", "c1", None, "text"), mock.Mock(return_value=p1), ttl_seconds=None
|
||||
)
|
||||
cache.get_or_fetch(
|
||||
("p", "c2", None, "text"), mock.Mock(return_value=p2), ttl_seconds=None
|
||||
)
|
||||
assert cache.get(("p", "c1", None, "text")) is p1
|
||||
assert cache.get(("p", "c2", None, "text")) is p2
|
||||
|
||||
|
||||
class TestBackgroundRefresh:
|
||||
def test_refresh__stale_entry__updates_prompt(self):
|
||||
new_prompt = _make_mock_prompt(commit=None)
|
||||
callback = mock.Mock(return_value=new_prompt)
|
||||
|
||||
cache = get_global_cache()
|
||||
base = time.monotonic()
|
||||
with mock.patch("opik.api_objects.prompt.prompt_cache.time") as mock_time:
|
||||
mock_time.monotonic.return_value = base
|
||||
prompt_cache.get_or_fetch("p", None, None, "text", callback)
|
||||
callback.reset_mock()
|
||||
callback.return_value = new_prompt
|
||||
|
||||
mock_time.monotonic.return_value = base + _get_ttl() + 1
|
||||
mock_time.sleep = mock.Mock()
|
||||
cache._refresh_stale_entries()
|
||||
|
||||
assert callback.call_count >= 1
|
||||
assert cache.get(("p", None, None, "text", None, None)) is new_prompt
|
||||
|
||||
def test_refresh__non_stale_entry__skips_callback(self):
|
||||
p = _make_mock_prompt(commit=None)
|
||||
callback = mock.Mock(return_value=p)
|
||||
|
||||
cache = get_global_cache()
|
||||
base = time.monotonic()
|
||||
with mock.patch("opik.api_objects.prompt.prompt_cache.time") as mock_time:
|
||||
mock_time.monotonic.return_value = base
|
||||
prompt_cache.get_or_fetch("p", None, None, "text", callback)
|
||||
callback.reset_mock()
|
||||
|
||||
mock_time.monotonic.return_value = base + 0.2
|
||||
cache._refresh_stale_entries()
|
||||
|
||||
callback.assert_not_called()
|
||||
|
||||
def test_refresh__callback_raises__thread_survives(self):
|
||||
p = _make_mock_prompt(commit=None)
|
||||
callback = mock.Mock(side_effect=[p, RuntimeError("boom")])
|
||||
|
||||
cache = get_global_cache()
|
||||
base = time.monotonic()
|
||||
with mock.patch("opik.api_objects.prompt.prompt_cache.time") as mock_time:
|
||||
mock_time.monotonic.return_value = base
|
||||
prompt_cache.get_or_fetch("p", None, None, "text", callback)
|
||||
|
||||
mock_time.monotonic.return_value = base + _get_ttl() + 1
|
||||
cache._refresh_stale_entries()
|
||||
|
||||
assert cache._thread.is_alive()
|
||||
|
||||
|
||||
class TestGetOrFetch:
|
||||
def test_get_or_fetch__cache_miss__calls_fetch_fn(self):
|
||||
p = _make_mock_prompt()
|
||||
fetch_fn = mock.Mock(return_value=p)
|
||||
result = prompt_cache.get_or_fetch("p", None, None, "text", fetch_fn)
|
||||
assert result is p
|
||||
fetch_fn.assert_called_once()
|
||||
|
||||
def test_get_or_fetch__fetch_returns_none__returns_none(self):
|
||||
fetch_fn = mock.Mock(return_value=None)
|
||||
result = prompt_cache.get_or_fetch("missing", None, None, "text", fetch_fn)
|
||||
assert result is None
|
||||
|
||||
def test_get_or_fetch__cache_hit__does_not_call_fetch_fn(self):
|
||||
p = _make_mock_prompt()
|
||||
fetch_fn = mock.Mock(return_value=p)
|
||||
prompt_cache.get_or_fetch("p", None, None, "text", fetch_fn)
|
||||
fetch_fn.reset_mock()
|
||||
|
||||
result = prompt_cache.get_or_fetch("p", None, None, "text", fetch_fn)
|
||||
assert result is p
|
||||
fetch_fn.assert_not_called()
|
||||
|
||||
def test_get_or_fetch__pinned_commit__no_refresh_thread(self):
|
||||
p = _make_mock_prompt(commit="abc")
|
||||
fetch_fn = mock.Mock(return_value=p)
|
||||
prompt_cache.get_or_fetch("p", "abc", None, "text", fetch_fn)
|
||||
cache = get_global_cache()
|
||||
assert cache._thread is None
|
||||
|
||||
def test_get_or_fetch__unpinned_commit__starts_refresh_thread(self):
|
||||
p = _make_mock_prompt(commit=None)
|
||||
fetch_fn = mock.Mock(return_value=p)
|
||||
prompt_cache.get_or_fetch("p", None, None, "text", fetch_fn)
|
||||
cache = get_global_cache()
|
||||
assert cache._thread is not None
|
||||
assert cache._thread.is_alive()
|
||||
|
||||
def test_get_or_fetch__cache_hit__returns_same_object(self):
|
||||
p = _make_mock_prompt()
|
||||
fetch_fn = mock.Mock(return_value=p)
|
||||
prompt_cache.get_or_fetch("p", None, "proj", "text", fetch_fn)
|
||||
first = prompt_cache.get_or_fetch("p", None, "proj", "text", fetch_fn)
|
||||
second = prompt_cache.get_or_fetch("p", None, "proj", "text", fetch_fn)
|
||||
assert first is second is p
|
||||
|
||||
def test_get_or_fetch__different_keys__returns_separate_entries(self):
|
||||
p1 = _make_mock_prompt(commit="c1")
|
||||
p2 = _make_mock_prompt(commit="c2")
|
||||
prompt_cache.get_or_fetch("p", "c1", None, "text", mock.Mock(return_value=p1))
|
||||
prompt_cache.get_or_fetch("p", "c2", None, "text", mock.Mock(return_value=p2))
|
||||
r1 = prompt_cache.get_or_fetch("p", "c1", None, "text", mock.Mock())
|
||||
r2 = prompt_cache.get_or_fetch("p", "c2", None, "text", mock.Mock())
|
||||
assert r1 is p1
|
||||
assert r2 is p2
|
||||
|
||||
|
||||
class TestGetOrFetchVersionSelector:
|
||||
"""Tests for the ``version`` parameter on the module-level ``get_or_fetch``."""
|
||||
|
||||
def test_get_or_fetch__different_versions__return_separate_entries(self):
|
||||
p1 = _make_mock_prompt(commit="commitA")
|
||||
p2 = _make_mock_prompt(commit="commitB")
|
||||
fetch1 = mock.Mock(return_value=p1)
|
||||
fetch2 = mock.Mock(return_value=p2)
|
||||
|
||||
first = prompt_cache.get_or_fetch("p", None, None, "text", fetch1, version="v1")
|
||||
second = prompt_cache.get_or_fetch(
|
||||
"p", None, None, "text", fetch2, version="v2"
|
||||
)
|
||||
|
||||
assert first is p1
|
||||
assert second is p2
|
||||
fetch1.assert_called_once()
|
||||
fetch2.assert_called_once()
|
||||
|
||||
def test_get_or_fetch__same_version__second_call_hits_cache(self):
|
||||
p = _make_mock_prompt()
|
||||
fetch_fn = mock.Mock(return_value=p)
|
||||
|
||||
first = prompt_cache.get_or_fetch(
|
||||
"p", None, None, "text", fetch_fn, version="v2"
|
||||
)
|
||||
second = prompt_cache.get_or_fetch(
|
||||
"p", None, None, "text", fetch_fn, version="v2"
|
||||
)
|
||||
|
||||
assert first is second is p
|
||||
fetch_fn.assert_called_once()
|
||||
|
||||
def test_get_or_fetch__commit_pin_and_version_pin__do_not_collide(self):
|
||||
p_commit = _make_mock_prompt(commit="abc12345")
|
||||
p_version = _make_mock_prompt(commit="def67890")
|
||||
fetch_commit = mock.Mock(return_value=p_commit)
|
||||
fetch_version = mock.Mock(return_value=p_version)
|
||||
|
||||
from_commit = prompt_cache.get_or_fetch(
|
||||
"p", "abc12345", None, "text", fetch_commit
|
||||
)
|
||||
from_version = prompt_cache.get_or_fetch(
|
||||
"p", None, None, "text", fetch_version, version="v3"
|
||||
)
|
||||
|
||||
assert from_commit is p_commit
|
||||
assert from_version is p_version
|
||||
fetch_commit.assert_called_once()
|
||||
fetch_version.assert_called_once()
|
||||
|
||||
def test_get_or_fetch__version_selector__starts_refresh_thread(self):
|
||||
# Sequential versions can be reassigned by the backend if the underlying
|
||||
# version is deleted and recreated, so they must follow the normal TTL
|
||||
# refresh (not pinned indefinitely).
|
||||
p = _make_mock_prompt()
|
||||
fetch_fn = mock.Mock(return_value=p)
|
||||
prompt_cache.get_or_fetch("p", None, None, "text", fetch_fn, version="v1")
|
||||
cache = get_global_cache()
|
||||
assert cache._thread is not None
|
||||
assert cache._thread.is_alive()
|
||||
|
||||
|
||||
class TestPromptCacheEdgeCases:
|
||||
def test_get_or_fetch__multiple_unpinned_inserts__reuses_single_refresh_thread(
|
||||
self, cache
|
||||
):
|
||||
p = _make_mock_prompt()
|
||||
cache.get_or_fetch(
|
||||
("a", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=300
|
||||
)
|
||||
thread1 = cache._thread
|
||||
cache.get_or_fetch(
|
||||
("b", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=300
|
||||
)
|
||||
assert cache._thread is thread1
|
||||
|
||||
def test_clear__on_empty_cache__is_noop(self, cache):
|
||||
cache.clear()
|
||||
assert cache.get(("any", None, None, "text")) is None
|
||||
|
||||
def test_refresh__pinned_entry__not_refreshed_even_after_ttl(self, cache):
|
||||
p = _make_mock_prompt()
|
||||
callback = mock.Mock(return_value=p)
|
||||
|
||||
base = time.monotonic()
|
||||
with mock.patch("opik.api_objects.prompt.prompt_cache.time") as mock_time:
|
||||
mock_time.monotonic.return_value = base
|
||||
cache.get_or_fetch(("p", "v1", None, "text"), callback, ttl_seconds=None)
|
||||
callback.reset_mock()
|
||||
|
||||
mock_time.monotonic.return_value = base + 0.2
|
||||
cache._refresh_stale_entries()
|
||||
|
||||
callback.assert_not_called()
|
||||
assert cache.get(("p", "v1", None, "text")) is p
|
||||
|
||||
def test_get_or_fetch__fetch_returns_none__not_cached(self, cache):
|
||||
fetch_fn = mock.Mock(return_value=None)
|
||||
cache.get_or_fetch(("p", None, None, "text"), fetch_fn, ttl_seconds=300)
|
||||
assert cache.get(("p", None, None, "text")) is None
|
||||
|
||||
def test_refresh__callback_returns_none__preserves_original_prompt(self):
|
||||
original = _make_mock_prompt(commit=None)
|
||||
callback = mock.Mock(side_effect=[original, None])
|
||||
|
||||
cache = get_global_cache()
|
||||
base = time.monotonic()
|
||||
with mock.patch("opik.api_objects.prompt.prompt_cache.time") as mock_time:
|
||||
mock_time.monotonic.return_value = base
|
||||
prompt_cache.get_or_fetch("p", None, None, "text", callback)
|
||||
|
||||
mock_time.monotonic.return_value = base + _get_ttl() + 1
|
||||
cache._refresh_stale_entries()
|
||||
|
||||
assert cache.get(("p", None, None, "text", None, None)) is original
|
||||
|
||||
def test_lru_eviction__oldest_entry_removed_when_max_size_exceeded(self):
|
||||
cache = PromptCache(max_size=3)
|
||||
prompts = [_make_mock_prompt(commit=f"c{i}") for i in range(4)]
|
||||
for i, p in enumerate(prompts):
|
||||
cache.get_or_fetch(
|
||||
(f"p{i}", f"c{i}", None, "text"),
|
||||
mock.Mock(return_value=p),
|
||||
ttl_seconds=None,
|
||||
)
|
||||
|
||||
assert cache.get(("p0", "c0", None, "text")) is None
|
||||
assert cache.get(("p1", "c1", None, "text")) is prompts[1]
|
||||
assert cache.get(("p2", "c2", None, "text")) is prompts[2]
|
||||
assert cache.get(("p3", "c3", None, "text")) is prompts[3]
|
||||
cache.clear()
|
||||
|
||||
def test_lru_eviction__access_refreshes_position(self):
|
||||
cache = PromptCache(max_size=3)
|
||||
prompts = [_make_mock_prompt(commit=f"c{i}") for i in range(3)]
|
||||
for i, p in enumerate(prompts):
|
||||
cache.get_or_fetch(
|
||||
(f"p{i}", f"c{i}", None, "text"),
|
||||
mock.Mock(return_value=p),
|
||||
ttl_seconds=None,
|
||||
)
|
||||
|
||||
# Access p0 so it becomes most-recently-used
|
||||
cache.get(("p0", "c0", None, "text"))
|
||||
|
||||
# Insert a 4th entry — p1 (the actual LRU) should be evicted, not p0
|
||||
p3 = _make_mock_prompt(commit="c3")
|
||||
cache.get_or_fetch(
|
||||
("p3", "c3", None, "text"),
|
||||
mock.Mock(return_value=p3),
|
||||
ttl_seconds=None,
|
||||
)
|
||||
|
||||
assert cache.get(("p0", "c0", None, "text")) is prompts[0]
|
||||
assert cache.get(("p1", "c1", None, "text")) is None
|
||||
assert cache.get(("p2", "c2", None, "text")) is prompts[2]
|
||||
assert cache.get(("p3", "c3", None, "text")) is p3
|
||||
cache.clear()
|
||||
|
||||
def test_clear__called_twice__is_safe(self, cache):
|
||||
p = _make_mock_prompt()
|
||||
cache.get_or_fetch(
|
||||
("p", None, None, "text"), mock.Mock(return_value=p), ttl_seconds=300
|
||||
)
|
||||
cache.clear()
|
||||
cache.clear()
|
||||
assert cache._thread is None
|
||||
|
||||
|
||||
class TestPromptAutoInjection:
|
||||
"""Test that get_prompt injects prompts into the active trace/span context via opik_prompts."""
|
||||
|
||||
@staticmethod
|
||||
def _make_prompt_with_info_dict(name="my-prompt", commit="abc123", info_dict=None):
|
||||
p = _make_mock_prompt(name=name, commit=commit)
|
||||
p.__internal_api__to_info_dict__ = mock.Mock(
|
||||
return_value=info_dict or {"name": name, "version": {"commit": commit}}
|
||||
)
|
||||
return p
|
||||
|
||||
def _call_get_prompt(self, cache_return_value):
|
||||
from opik.api_objects import opik_client
|
||||
|
||||
client = opik_client.Opik()
|
||||
with mock.patch(
|
||||
"opik.api_objects.prompt.prompt_cache.get_or_fetch",
|
||||
return_value=cache_return_value,
|
||||
):
|
||||
return client.get_prompt(
|
||||
name="my-prompt", commit="abc123", project_name=None
|
||||
)
|
||||
|
||||
def test_get_prompt__in_track_context__injects_into_metadata(self):
|
||||
info_dict = {"name": "my-prompt", "version": {"commit": "abc123"}}
|
||||
mock_prompt = self._make_prompt_with_info_dict(info_dict=info_dict)
|
||||
|
||||
mock_trace_data = mock.Mock()
|
||||
mock_trace_data.metadata = None
|
||||
mock_span_data = mock.Mock()
|
||||
mock_span_data.metadata = None
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"opik.context_storage.get_trace_data", return_value=mock_trace_data
|
||||
),
|
||||
mock.patch(
|
||||
"opik.context_storage.top_span_data", return_value=mock_span_data
|
||||
),
|
||||
):
|
||||
self._call_get_prompt(mock_prompt)
|
||||
|
||||
mock_trace_data.update.assert_called_once_with(
|
||||
metadata={"opik_prompts": [info_dict]}
|
||||
)
|
||||
mock_span_data.update.assert_called_once_with(
|
||||
metadata={"opik_prompts": [info_dict]}
|
||||
)
|
||||
|
||||
def test_get_prompt__appends_to_existing_prompts(self):
|
||||
existing_prompt_info = {"name": "old-prompt", "version": {"commit": "old123"}}
|
||||
new_info_dict = {"name": "my-prompt", "version": {"commit": "abc123"}}
|
||||
mock_prompt = self._make_prompt_with_info_dict(info_dict=new_info_dict)
|
||||
|
||||
mock_trace_data = mock.Mock()
|
||||
mock_trace_data.metadata = {
|
||||
"opik_prompts": [existing_prompt_info],
|
||||
"other_key": "value",
|
||||
}
|
||||
mock_span_data = mock.Mock()
|
||||
mock_span_data.metadata = {"opik_prompts": [existing_prompt_info]}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"opik.context_storage.get_trace_data", return_value=mock_trace_data
|
||||
),
|
||||
mock.patch(
|
||||
"opik.context_storage.top_span_data", return_value=mock_span_data
|
||||
),
|
||||
):
|
||||
self._call_get_prompt(mock_prompt)
|
||||
|
||||
mock_trace_data.update.assert_called_once_with(
|
||||
metadata={"opik_prompts": [existing_prompt_info, new_info_dict]}
|
||||
)
|
||||
mock_span_data.update.assert_called_once_with(
|
||||
metadata={"opik_prompts": [existing_prompt_info, new_info_dict]}
|
||||
)
|
||||
|
||||
def test_get_prompt__no_track_context__no_error(self):
|
||||
"""When there is no active trace context, injection silently does nothing."""
|
||||
mock_prompt = self._make_prompt_with_info_dict()
|
||||
|
||||
with (
|
||||
mock.patch("opik.context_storage.get_trace_data", return_value=None),
|
||||
mock.patch("opik.context_storage.top_span_data", return_value=None),
|
||||
):
|
||||
result = self._call_get_prompt(mock_prompt)
|
||||
assert result is mock_prompt
|
||||
|
||||
def test_get_prompt__none_result__no_injection(self):
|
||||
with (
|
||||
mock.patch("opik.context_storage.get_trace_data") as mock_get_trace,
|
||||
mock.patch("opik.context_storage.top_span_data") as mock_top_span,
|
||||
):
|
||||
result = self._call_get_prompt(None)
|
||||
|
||||
assert result is None
|
||||
mock_get_trace.assert_not_called()
|
||||
mock_top_span.assert_not_called()
|
||||
@@ -0,0 +1,793 @@
|
||||
"""Unit tests for PromptClient to verify API endpoint selection logic."""
|
||||
|
||||
from unittest import mock
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from opik import exceptions
|
||||
from opik.api_objects import opik_client as opik_client_module
|
||||
from opik.api_objects.prompt import client as prompt_client
|
||||
from opik.api_objects.prompt import prompt_cache
|
||||
from opik.api_objects.prompt import types as prompt_types
|
||||
from opik.rest_api import core as rest_api_core
|
||||
from opik.rest_api.types import prompt_version_detail
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rest_client():
|
||||
"""Create a mock REST client."""
|
||||
client = mock.Mock()
|
||||
client.prompts = mock.Mock()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_rest_client):
|
||||
"""Create a PromptClient with a mock REST client."""
|
||||
return prompt_client.PromptClient(mock_rest_client)
|
||||
|
||||
|
||||
def _make_mock_version(
|
||||
template: str = "test template",
|
||||
version_type: str = "mustache",
|
||||
metadata: Optional[dict] = None,
|
||||
version_number: Optional[str] = "v1",
|
||||
tags: Optional[list] = None,
|
||||
environments: Optional[list] = None,
|
||||
) -> prompt_version_detail.PromptVersionDetail:
|
||||
"""Helper to create a mock PromptVersionDetail."""
|
||||
return prompt_version_detail.PromptVersionDetail(
|
||||
id="version-id",
|
||||
prompt_id="prompt-id",
|
||||
template=template,
|
||||
type=version_type,
|
||||
metadata=metadata,
|
||||
commit="abc123",
|
||||
version_number=version_number,
|
||||
tags=tags,
|
||||
environments=environments,
|
||||
template_structure="text",
|
||||
)
|
||||
|
||||
|
||||
def _make_404_error() -> rest_api_core.ApiError:
|
||||
"""Helper to create a 404 ApiError."""
|
||||
error = rest_api_core.ApiError(status_code=404, body=None)
|
||||
return error
|
||||
|
||||
|
||||
class TestPromptClientEndpointSelection:
|
||||
"""Tests to verify that PromptClient calls the correct REST endpoint based on parameters."""
|
||||
|
||||
def test_create_new_prompt_with_container_params_calls_create_prompt(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
"""When creating a new prompt with id/description/tags, should call create_prompt endpoint.
|
||||
Also asserts that version_number survives the tag-rebuild branch."""
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
|
||||
_make_404_error(),
|
||||
_make_mock_version(tags=None),
|
||||
]
|
||||
|
||||
result = client.create_prompt(
|
||||
name="test-prompt",
|
||||
prompt="test template",
|
||||
metadata=None,
|
||||
type=prompt_types.PromptType.MUSTACHE,
|
||||
id="custom-id",
|
||||
description="A test prompt",
|
||||
tags=["test", "unit"],
|
||||
)
|
||||
|
||||
mock_rest_client.prompts.create_prompt.assert_called_once()
|
||||
mock_rest_client.prompts.create_prompt_version.assert_not_called()
|
||||
|
||||
call_kwargs = mock_rest_client.prompts.create_prompt.call_args[1]
|
||||
assert call_kwargs["name"] == "test-prompt"
|
||||
assert call_kwargs["id"] == "custom-id"
|
||||
assert call_kwargs["description"] == "A test prompt"
|
||||
assert call_kwargs["tags"] == ["test", "unit"]
|
||||
assert result.version_number == "v1"
|
||||
assert result.tags == ["test", "unit"]
|
||||
|
||||
def test_create_new_prompt_without_container_params_calls_create_version(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
"""When creating a new prompt without id/description/tags, should call create_prompt_version.
|
||||
Also asserts that version_number from the create response is surfaced."""
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = _make_404_error()
|
||||
mock_rest_client.prompts.create_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
result = client.create_prompt(
|
||||
name="test-prompt",
|
||||
prompt="test template",
|
||||
metadata=None,
|
||||
type=prompt_types.PromptType.MUSTACHE,
|
||||
)
|
||||
|
||||
mock_rest_client.prompts.create_prompt_version.assert_called_once()
|
||||
mock_rest_client.prompts.create_prompt.assert_not_called()
|
||||
assert result.version_number == "v1"
|
||||
|
||||
def test_update_existing_prompt_always_calls_create_version(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
"""When updating an existing prompt, should always call create_prompt_version regardless of params."""
|
||||
existing_version = _make_mock_version(template="old template")
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = existing_version
|
||||
mock_rest_client.prompts.create_prompt_version.return_value = (
|
||||
_make_mock_version(template="new template")
|
||||
)
|
||||
|
||||
client.create_prompt(
|
||||
name="test-prompt",
|
||||
prompt="new template",
|
||||
metadata=None,
|
||||
type=prompt_types.PromptType.MUSTACHE,
|
||||
id="custom-id",
|
||||
description="Updated description",
|
||||
tags=["updated"],
|
||||
)
|
||||
|
||||
mock_rest_client.prompts.create_prompt_version.assert_called_once()
|
||||
mock_rest_client.prompts.create_prompt.assert_not_called()
|
||||
|
||||
def test_create_new_prompt_with_only_id_calls_create_prompt(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
"""When creating a new prompt with only id parameter, should call create_prompt."""
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
|
||||
_make_404_error(),
|
||||
_make_mock_version(),
|
||||
]
|
||||
|
||||
client.create_prompt(
|
||||
name="test-prompt",
|
||||
prompt="test template",
|
||||
metadata=None,
|
||||
type=prompt_types.PromptType.MUSTACHE,
|
||||
id="custom-id",
|
||||
)
|
||||
|
||||
mock_rest_client.prompts.create_prompt.assert_called_once()
|
||||
mock_rest_client.prompts.create_prompt_version.assert_not_called()
|
||||
|
||||
def test_create_new_prompt_with_only_description_calls_create_prompt(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
"""When creating a new prompt with only description parameter, should call create_prompt."""
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
|
||||
_make_404_error(),
|
||||
_make_mock_version(),
|
||||
]
|
||||
|
||||
client.create_prompt(
|
||||
name="test-prompt",
|
||||
prompt="test template",
|
||||
metadata=None,
|
||||
type=prompt_types.PromptType.MUSTACHE,
|
||||
description="A test prompt",
|
||||
)
|
||||
|
||||
mock_rest_client.prompts.create_prompt.assert_called_once()
|
||||
mock_rest_client.prompts.create_prompt_version.assert_not_called()
|
||||
|
||||
def test_create_new_prompt_with_only_tags_calls_create_prompt(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
"""When creating a new prompt with only tags parameter, should call create_prompt.
|
||||
Tags-only takes the rebuild branch — assert version_number survives it
|
||||
(regression for the bug where tag injection dropped version_number)."""
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
|
||||
_make_404_error(),
|
||||
_make_mock_version(tags=None),
|
||||
]
|
||||
|
||||
result = client.create_prompt(
|
||||
name="test-prompt",
|
||||
prompt="test template",
|
||||
metadata=None,
|
||||
type=prompt_types.PromptType.MUSTACHE,
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
mock_rest_client.prompts.create_prompt.assert_called_once()
|
||||
mock_rest_client.prompts.create_prompt_version.assert_not_called()
|
||||
assert result.version_number == "v1"
|
||||
assert result.tags == ["test"]
|
||||
|
||||
|
||||
class TestInternalCreateMask:
|
||||
"""Tests for __internal__create_mask method."""
|
||||
|
||||
def test_create_mask_calls_create_prompt_version_with_mask_type(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
expected = _make_mock_version()
|
||||
mock_rest_client.prompts.create_prompt_version.return_value = expected
|
||||
|
||||
result = client._PromptClient__internal_api__create_mask(
|
||||
name="test-prompt",
|
||||
prompt="masked template",
|
||||
)
|
||||
|
||||
mock_rest_client.prompts.create_prompt_version.assert_called_once()
|
||||
call_kwargs = mock_rest_client.prompts.create_prompt_version.call_args[1]
|
||||
assert call_kwargs["name"] == "test-prompt"
|
||||
assert call_kwargs["version"].template == "masked template"
|
||||
assert call_kwargs["version"].version_type == "mask"
|
||||
assert result == expected
|
||||
|
||||
def test_create_mask_passes_all_parameters(self, client, mock_rest_client):
|
||||
mock_rest_client.prompts.create_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
client._PromptClient__internal_api__create_mask(
|
||||
name="test-prompt",
|
||||
prompt="masked template",
|
||||
type=prompt_types.PromptType.JINJA2,
|
||||
metadata={"key": "value"},
|
||||
template_structure="chat",
|
||||
project_name="my-project",
|
||||
change_description="mask for testing",
|
||||
)
|
||||
|
||||
call_kwargs = mock_rest_client.prompts.create_prompt_version.call_args[1]
|
||||
assert call_kwargs["name"] == "test-prompt"
|
||||
assert call_kwargs["template_structure"] == "chat"
|
||||
assert call_kwargs["project_name"] == "my-project"
|
||||
version = call_kwargs["version"]
|
||||
assert version.template == "masked template"
|
||||
assert version.version_type == "mask"
|
||||
assert version.type == prompt_types.PromptType.JINJA2
|
||||
assert version.metadata == {"key": "value"}
|
||||
assert version.change_description == "mask for testing"
|
||||
|
||||
def test_create_mask_defaults(self, client, mock_rest_client):
|
||||
mock_rest_client.prompts.create_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
client._PromptClient__internal_api__create_mask(
|
||||
name="test-prompt",
|
||||
prompt="template",
|
||||
)
|
||||
|
||||
call_kwargs = mock_rest_client.prompts.create_prompt_version.call_args[1]
|
||||
assert call_kwargs["template_structure"] == "text"
|
||||
assert call_kwargs["project_name"] is None
|
||||
version = call_kwargs["version"]
|
||||
assert version.type == prompt_types.PromptType.MUSTACHE
|
||||
assert version.metadata is None
|
||||
assert version.change_description is None
|
||||
|
||||
|
||||
class TestGetPromptByVersionSelector:
|
||||
"""Tests for the new sequential ``version`` parameter (e.g. ``"v3"``)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_global_cache(self):
|
||||
yield
|
||||
prompt_cache.get_global_cache().clear()
|
||||
|
||||
def test_get_prompt__version_arg__threads_through_as_version_number(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
client.get_prompt(name="my-prompt", version="v3")
|
||||
|
||||
mock_rest_client.prompts.retrieve_prompt_version.assert_called_once()
|
||||
call_kwargs = mock_rest_client.prompts.retrieve_prompt_version.call_args[1]
|
||||
assert call_kwargs["name"] == "my-prompt"
|
||||
assert call_kwargs["version_number"] == "v3"
|
||||
assert call_kwargs["commit"] is None
|
||||
|
||||
def test_get_prompt__commit_and_version_both_set__raises_value_error(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
with pytest.raises(ValueError, match=r"Provide either `commit` or `version`"):
|
||||
client.get_prompt(name="my-prompt", commit="abc12345", version="v1")
|
||||
mock_rest_client.prompts.retrieve_prompt_version.assert_not_called()
|
||||
|
||||
def test_get_prompt_with_cache__commit_and_version_both_set__raises_value_error(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
from opik.api_objects.prompt.text import prompt as text_prompt_module
|
||||
|
||||
with pytest.raises(ValueError, match=r"Provide either `commit` or `version`"):
|
||||
client.get_prompt_with_cache(
|
||||
name="my-prompt",
|
||||
commit="abc12345",
|
||||
project_name=None,
|
||||
template_structure="text",
|
||||
prompt_cls=text_prompt_module.Prompt,
|
||||
version="v1",
|
||||
)
|
||||
|
||||
def test_get_prompt_with_cache__commit_and_environment__raises_value_error(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
from opik.api_objects.prompt.text import prompt as text_prompt_module
|
||||
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
client.get_prompt_with_cache(
|
||||
name="my-prompt",
|
||||
commit="abc12345",
|
||||
project_name=None,
|
||||
template_structure="text",
|
||||
prompt_cls=text_prompt_module.Prompt,
|
||||
environment="staging",
|
||||
)
|
||||
|
||||
def test_get_prompt_with_cache__version_and_environment__raises_value_error(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
from opik.api_objects.prompt.text import prompt as text_prompt_module
|
||||
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
client.get_prompt_with_cache(
|
||||
name="my-prompt",
|
||||
commit=None,
|
||||
project_name=None,
|
||||
template_structure="text",
|
||||
prompt_cls=text_prompt_module.Prompt,
|
||||
version="v1",
|
||||
environment="staging",
|
||||
)
|
||||
|
||||
def test_get_prompt_with_cache__different_versions__do_not_collide_in_cache(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
from opik.api_objects.prompt.text import prompt as text_prompt_module
|
||||
|
||||
v1 = _make_mock_version(template="content for v1")
|
||||
v2 = _make_mock_version(template="content for v2")
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [v1, v2]
|
||||
|
||||
first = client.get_prompt_with_cache(
|
||||
name="my-prompt",
|
||||
commit=None,
|
||||
project_name=None,
|
||||
template_structure="text",
|
||||
prompt_cls=text_prompt_module.Prompt,
|
||||
version="v1",
|
||||
)
|
||||
second = client.get_prompt_with_cache(
|
||||
name="my-prompt",
|
||||
commit=None,
|
||||
project_name=None,
|
||||
template_structure="text",
|
||||
prompt_cls=text_prompt_module.Prompt,
|
||||
version="v2",
|
||||
)
|
||||
|
||||
assert first is not None and first.prompt == "content for v1"
|
||||
assert second is not None and second.prompt == "content for v2"
|
||||
assert mock_rest_client.prompts.retrieve_prompt_version.call_count == 2
|
||||
|
||||
def test_get_prompt_with_cache__commit_and_version_pin__do_not_collide(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
from opik.api_objects.prompt.text import prompt as text_prompt_module
|
||||
|
||||
by_commit = _make_mock_version(template="content by commit")
|
||||
by_version = _make_mock_version(template="content by version")
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
|
||||
by_commit,
|
||||
by_version,
|
||||
]
|
||||
|
||||
commit_result = client.get_prompt_with_cache(
|
||||
name="my-prompt",
|
||||
commit="abc12345",
|
||||
project_name=None,
|
||||
template_structure="text",
|
||||
prompt_cls=text_prompt_module.Prompt,
|
||||
)
|
||||
version_result = client.get_prompt_with_cache(
|
||||
name="my-prompt",
|
||||
commit=None,
|
||||
project_name=None,
|
||||
template_structure="text",
|
||||
prompt_cls=text_prompt_module.Prompt,
|
||||
version="v3",
|
||||
)
|
||||
|
||||
assert commit_result is not None
|
||||
assert commit_result.prompt == "content by commit"
|
||||
assert version_result is not None
|
||||
assert version_result.prompt == "content by version"
|
||||
assert mock_rest_client.prompts.retrieve_prompt_version.call_count == 2
|
||||
|
||||
def test_get_prompt_with_cache__same_version_twice__second_hits_cache(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
from opik.api_objects.prompt.text import prompt as text_prompt_module
|
||||
|
||||
v2 = _make_mock_version(template="v2 content")
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = v2
|
||||
|
||||
first = client.get_prompt_with_cache(
|
||||
name="my-prompt",
|
||||
commit=None,
|
||||
project_name=None,
|
||||
template_structure="text",
|
||||
prompt_cls=text_prompt_module.Prompt,
|
||||
version="v2",
|
||||
)
|
||||
second = client.get_prompt_with_cache(
|
||||
name="my-prompt",
|
||||
commit=None,
|
||||
project_name=None,
|
||||
template_structure="text",
|
||||
prompt_cls=text_prompt_module.Prompt,
|
||||
version="v2",
|
||||
)
|
||||
|
||||
assert first is second
|
||||
assert mock_rest_client.prompts.retrieve_prompt_version.call_count == 1
|
||||
|
||||
|
||||
class TestGetPromptWithCacheBypass:
|
||||
"""Tests for no_cache parameter in Opik.get_prompt()."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_global_cache(self):
|
||||
yield
|
||||
prompt_cache.get_global_cache().clear()
|
||||
|
||||
@pytest.fixture
|
||||
def opik_client(self, mock_rest_client):
|
||||
client = opik_client_module.Opik()
|
||||
client._rest_client = mock_rest_client
|
||||
return client
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"no_cache, expected_extra_calls",
|
||||
[
|
||||
(False, 0),
|
||||
(True, 1),
|
||||
],
|
||||
ids=["no_cache_false__uses_cache", "no_cache_true__hits_backend"],
|
||||
)
|
||||
def test_get_prompt__second_call_after_cache_warm__respects_no_cache(
|
||||
self, opik_client, mock_rest_client, no_cache, expected_extra_calls
|
||||
):
|
||||
version = _make_mock_version()
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = version
|
||||
|
||||
opik_client.get_prompt(name="my-prompt", commit=None, project_name=None)
|
||||
call_count_after_warm = (
|
||||
mock_rest_client.prompts.retrieve_prompt_version.call_count
|
||||
)
|
||||
|
||||
opik_client.get_prompt(
|
||||
name="my-prompt", commit=None, project_name=None, no_cache=no_cache
|
||||
)
|
||||
|
||||
assert (
|
||||
mock_rest_client.prompts.retrieve_prompt_version.call_count
|
||||
== call_count_after_warm + expected_extra_calls
|
||||
)
|
||||
|
||||
def test_get_prompt__no_cache_true__returns_fresh_value_from_backend(
|
||||
self, opik_client, mock_rest_client
|
||||
):
|
||||
old_version = _make_mock_version(template="old template")
|
||||
new_version = _make_mock_version(template="new template")
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
|
||||
old_version,
|
||||
new_version,
|
||||
]
|
||||
|
||||
opik_client.get_prompt(name="my-prompt", commit=None, project_name=None)
|
||||
|
||||
result = opik_client.get_prompt(
|
||||
name="my-prompt", commit=None, project_name=None, no_cache=True
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.prompt == "new template"
|
||||
|
||||
def test_get_prompt__no_cache_true__backend_returns_none__returns_none(
|
||||
self, opik_client, mock_rest_client
|
||||
):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = _make_404_error()
|
||||
|
||||
result = opik_client.get_prompt(
|
||||
name="missing-prompt", commit=None, project_name=None, no_cache=True
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPromptEnvironment:
|
||||
"""Unit tests for the prompt ``environment`` plumbing."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_global_cache(self):
|
||||
yield
|
||||
prompt_cache.get_global_cache().clear()
|
||||
|
||||
def test_get_prompt__forwards_environment_to_retrieve(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
client.get_prompt(name="env-prompt", environment="staging")
|
||||
|
||||
mock_rest_client.prompts.retrieve_prompt_version.assert_called_once()
|
||||
call_kwargs = mock_rest_client.prompts.retrieve_prompt_version.call_args[1]
|
||||
assert call_kwargs["environment"] == "staging"
|
||||
|
||||
def test_get_prompt__commit_and_environment__raises_value_error(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
client.get_prompt(
|
||||
name="env-prompt", commit="abc12345", environment="staging"
|
||||
)
|
||||
|
||||
mock_rest_client.prompts.retrieve_prompt_version.assert_not_called()
|
||||
|
||||
def test_get_prompt__version_and_environment__raises_value_error(
|
||||
self, client, mock_rest_client
|
||||
):
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
client.get_prompt(name="env-prompt", version="v3", environment="staging")
|
||||
|
||||
mock_rest_client.prompts.retrieve_prompt_version.assert_not_called()
|
||||
|
||||
def test_get_prompt_with_cache__different_environments__not_cached_together(
|
||||
self, mock_rest_client
|
||||
):
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
staging_version = prompt_version_detail.PromptVersionDetail(
|
||||
id="staging-id",
|
||||
prompt_id="prompt-id",
|
||||
template="staging template",
|
||||
type="mustache",
|
||||
metadata=None,
|
||||
commit="aaa",
|
||||
template_structure="text",
|
||||
environments=["staging"],
|
||||
)
|
||||
production_version = prompt_version_detail.PromptVersionDetail(
|
||||
id="prod-id",
|
||||
prompt_id="prompt-id",
|
||||
template="prod template",
|
||||
type="mustache",
|
||||
metadata=None,
|
||||
commit="bbb",
|
||||
template_structure="text",
|
||||
environments=["production"],
|
||||
)
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = [
|
||||
staging_version,
|
||||
production_version,
|
||||
]
|
||||
|
||||
staging = opik_client.get_prompt(name="env-prompt", environment="staging")
|
||||
production = opik_client.get_prompt(name="env-prompt", environment="production")
|
||||
|
||||
assert staging is not None and production is not None
|
||||
assert staging.commit == "aaa"
|
||||
assert production.commit == "bbb"
|
||||
assert mock_rest_client.prompts.retrieve_prompt_version.call_count == 2
|
||||
|
||||
def test_set_prompt_environments__sets_via_latest_version(self, mock_rest_client):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
opik_client.set_prompt_environments("env-prompt", ["staging"])
|
||||
|
||||
mock_rest_client.prompts.retrieve_prompt_version.assert_called_once()
|
||||
retrieve_kwargs = mock_rest_client.prompts.retrieve_prompt_version.call_args[1]
|
||||
assert retrieve_kwargs["name"] == "env-prompt"
|
||||
mock_rest_client.prompts.set_prompt_version_environment.assert_called_once_with(
|
||||
version_id="version-id",
|
||||
environments=["staging"],
|
||||
)
|
||||
|
||||
def test_set_prompt_environments__empty_list_clears(self, mock_rest_client):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
opik_client.set_prompt_environments("env-prompt", [])
|
||||
|
||||
mock_rest_client.prompts.set_prompt_version_environment.assert_called_once_with(
|
||||
version_id="version-id",
|
||||
environments=[],
|
||||
)
|
||||
|
||||
def test_set_prompt_environments__multiple_envs_in_one_call(self, mock_rest_client):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
opik_client.set_prompt_environments("env-prompt", ["staging", "production"])
|
||||
|
||||
mock_rest_client.prompts.set_prompt_version_environment.assert_called_once_with(
|
||||
version_id="version-id",
|
||||
environments=["staging", "production"],
|
||||
)
|
||||
|
||||
def test_set_prompt_environments__deduplicates_input(self, mock_rest_client):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
opik_client.set_prompt_environments(
|
||||
"env-prompt", ["staging", "staging", "production"]
|
||||
)
|
||||
|
||||
mock_rest_client.prompts.set_prompt_version_environment.assert_called_once_with(
|
||||
version_id="version-id",
|
||||
environments=["staging", "production"],
|
||||
)
|
||||
|
||||
def test_set_prompt_environments__forwards_project_name(self, mock_rest_client):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
opik_client.set_prompt_environments(
|
||||
"env-prompt", ["staging"], project_name="my-project"
|
||||
)
|
||||
|
||||
mock_rest_client.prompts.retrieve_prompt_version.assert_called_once()
|
||||
retrieve_kwargs = mock_rest_client.prompts.retrieve_prompt_version.call_args[1]
|
||||
assert retrieve_kwargs["name"] == "env-prompt"
|
||||
assert retrieve_kwargs["project_name"] == "my-project"
|
||||
|
||||
def test_set_prompt_environments__forwards_version(self, mock_rest_client):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
opik_client.set_prompt_environments("env-prompt", ["staging"], version="v3")
|
||||
|
||||
mock_rest_client.prompts.retrieve_prompt_version.assert_called_once()
|
||||
retrieve_kwargs = mock_rest_client.prompts.retrieve_prompt_version.call_args[1]
|
||||
assert retrieve_kwargs["name"] == "env-prompt"
|
||||
# The SDK forwards ``version`` as the wire-level ``version_number`` field —
|
||||
# ``commit`` is no longer part of this method's surface.
|
||||
assert retrieve_kwargs["version_number"] == "v3"
|
||||
assert "commit" not in retrieve_kwargs
|
||||
mock_rest_client.prompts.set_prompt_version_environment.assert_called_once_with(
|
||||
version_id="version-id",
|
||||
environments=["staging"],
|
||||
)
|
||||
|
||||
def test_set_prompt_environments__prompt_not_found__raises_prompt_not_found(
|
||||
self, mock_rest_client
|
||||
):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = (
|
||||
rest_api_core.ApiError(status_code=404, body=None)
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
with pytest.raises(exceptions.PromptNotFoundError, match="missing-prompt"):
|
||||
opik_client.set_prompt_environments("missing-prompt", ["staging"])
|
||||
|
||||
mock_rest_client.prompts.set_prompt_version_environment.assert_not_called()
|
||||
|
||||
def test_set_prompt_environments__version_not_found__raises_prompt_not_found_with_version(
|
||||
self, mock_rest_client
|
||||
):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.side_effect = (
|
||||
rest_api_core.ApiError(status_code=404, body=None)
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
with pytest.raises(exceptions.PromptNotFoundError, match="v7"):
|
||||
opik_client.set_prompt_environments("env-prompt", ["staging"], version="v7")
|
||||
|
||||
mock_rest_client.prompts.set_prompt_version_environment.assert_not_called()
|
||||
|
||||
# The backend reports an unknown environment as 404 or 409 from the
|
||||
# workspace-registry check; both must surface as EnvironmentNotFoundError.
|
||||
@pytest.mark.parametrize("status_code", [404, 409])
|
||||
def test_set_prompt_environments__environment_not_found__raises_environment_not_found(
|
||||
self, mock_rest_client, status_code
|
||||
):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
mock_rest_client.prompts.set_prompt_version_environment.side_effect = (
|
||||
rest_api_core.ApiError(status_code=status_code, body=None)
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
with pytest.raises(exceptions.EnvironmentNotFoundError, match="unknown-env"):
|
||||
opik_client.set_prompt_environments("env-prompt", ["unknown-env"])
|
||||
|
||||
def test_set_prompt_environments__other_api_error__bubbles_up(
|
||||
self, mock_rest_client
|
||||
):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
mock_rest_client.prompts.set_prompt_version_environment.side_effect = (
|
||||
rest_api_core.ApiError(status_code=500, body=None)
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
|
||||
with pytest.raises(rest_api_core.ApiError):
|
||||
opik_client.set_prompt_environments("env-prompt", ["staging"])
|
||||
|
||||
def test_set_prompt_environments__invalidates_cache(self, mock_rest_client):
|
||||
mock_rest_client.prompts.retrieve_prompt_version.return_value = (
|
||||
_make_mock_version()
|
||||
)
|
||||
|
||||
opik_client = opik_client_module.Opik()
|
||||
opik_client._rest_client = mock_rest_client
|
||||
resolved_project = opik_client._resolve_project_name(None)
|
||||
|
||||
cache = prompt_cache.get_global_cache()
|
||||
cache.clear()
|
||||
try:
|
||||
sentinel = mock.MagicMock()
|
||||
cache.get_or_fetch(
|
||||
key=("env-prompt", None, resolved_project, "text", "staging"),
|
||||
fetch_fn=lambda: sentinel,
|
||||
ttl_seconds=None,
|
||||
)
|
||||
cache.get_or_fetch(
|
||||
key=("other-prompt", None, resolved_project, "text", "staging"),
|
||||
fetch_fn=lambda: sentinel,
|
||||
ttl_seconds=None,
|
||||
)
|
||||
|
||||
opik_client.set_prompt_environments("env-prompt", ["production"])
|
||||
|
||||
assert (
|
||||
cache.get(("env-prompt", None, resolved_project, "text", "staging"))
|
||||
is None
|
||||
), "stale entry for the updated prompt should be evicted"
|
||||
assert (
|
||||
cache.get(("other-prompt", None, resolved_project, "text", "staging"))
|
||||
is sentinel
|
||||
), "entries for unrelated prompts must not be evicted"
|
||||
finally:
|
||||
cache.clear()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for Prompt and ChatPrompt resilience when backend sync fails."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from opik.rest_api.core import ApiError
|
||||
from opik.api_objects.prompt.text.prompt import Prompt
|
||||
from opik.api_objects.prompt.chat.chat_prompt import ChatPrompt
|
||||
|
||||
|
||||
_API_ERRORS = [
|
||||
pytest.param(ApiError(status_code=500, body="Internal Server Error"), id="api_500"),
|
||||
pytest.param(ApiError(status_code=503, body="Service Unavailable"), id="api_503"),
|
||||
pytest.param(httpx.ConnectError("Connection refused"), id="connect_error"),
|
||||
pytest.param(httpx.TimeoutException("Request timed out"), id="timeout"),
|
||||
]
|
||||
|
||||
|
||||
def _mock_opik_client():
|
||||
"""Patch the opik_client.get_client_cached() used inside sync_with_backend."""
|
||||
return patch(
|
||||
"opik.api_objects.opik_client.get_client_cached",
|
||||
return_value=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
class TestPromptSyncFailure:
|
||||
"""Prompt should be created locally even when sync_with_backend fails with API errors."""
|
||||
|
||||
@pytest.mark.parametrize("error", _API_ERRORS)
|
||||
def test_prompt_created_despite_backend_failure(self, error):
|
||||
with (
|
||||
_mock_opik_client(),
|
||||
patch(
|
||||
"opik.api_objects.prompt.client.PromptClient.create_prompt",
|
||||
side_effect=error,
|
||||
),
|
||||
):
|
||||
prompt = Prompt(name="test-prompt", prompt="Hello {{name}}")
|
||||
|
||||
assert prompt.name == "test-prompt"
|
||||
assert prompt.prompt == "Hello {{name}}"
|
||||
assert prompt.commit is None
|
||||
assert prompt.synced is False
|
||||
assert prompt.format(name="World") == "Hello World"
|
||||
|
||||
def test_sync_returns_false_on_api_error(self):
|
||||
with (
|
||||
_mock_opik_client(),
|
||||
patch(
|
||||
"opik.api_objects.prompt.client.PromptClient.create_prompt",
|
||||
side_effect=ApiError(status_code=500),
|
||||
),
|
||||
):
|
||||
prompt = Prompt(name="test-prompt", prompt="Hello {{name}}")
|
||||
result = prompt.sync_with_backend()
|
||||
|
||||
assert result is False
|
||||
assert prompt.synced is False
|
||||
|
||||
def test_sync_returns_true_on_success(self):
|
||||
mock_version = MagicMock()
|
||||
mock_version.commit = "abc123"
|
||||
mock_version.prompt_id = "pid"
|
||||
mock_version.id = "vid"
|
||||
mock_version.change_description = None
|
||||
mock_version.tags = []
|
||||
|
||||
with (
|
||||
_mock_opik_client(),
|
||||
patch(
|
||||
"opik.api_objects.prompt.client.PromptClient.create_prompt",
|
||||
return_value=mock_version,
|
||||
),
|
||||
):
|
||||
prompt = Prompt(name="test-prompt", prompt="Hello {{name}}")
|
||||
|
||||
assert prompt.commit == "abc123"
|
||||
assert prompt.synced is True
|
||||
|
||||
# Re-sync also succeeds
|
||||
with (
|
||||
_mock_opik_client(),
|
||||
patch(
|
||||
"opik.api_objects.prompt.client.PromptClient.create_prompt",
|
||||
return_value=mock_version,
|
||||
),
|
||||
):
|
||||
assert prompt.sync_with_backend() is True
|
||||
assert prompt.synced is True
|
||||
|
||||
def test_non_api_error_propagates(self):
|
||||
with (
|
||||
_mock_opik_client(),
|
||||
patch(
|
||||
"opik.api_objects.prompt.client.PromptClient.create_prompt",
|
||||
side_effect=ValueError("bad value"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(ValueError, match="bad value"):
|
||||
Prompt(name="test-prompt", prompt="Hello {{name}}")
|
||||
|
||||
|
||||
class TestChatPromptSyncFailure:
|
||||
"""ChatPrompt should be created locally even when sync_with_backend fails with API errors."""
|
||||
|
||||
MESSAGES = [{"role": "user", "content": "Hello {{name}}"}]
|
||||
|
||||
@pytest.mark.parametrize("error", _API_ERRORS)
|
||||
def test_chat_prompt_created_despite_backend_failure(self, error):
|
||||
with (
|
||||
_mock_opik_client(),
|
||||
patch(
|
||||
"opik.api_objects.prompt.client.PromptClient.create_prompt",
|
||||
side_effect=error,
|
||||
),
|
||||
):
|
||||
prompt = ChatPrompt(name="test-chat", messages=self.MESSAGES)
|
||||
|
||||
assert prompt.name == "test-chat"
|
||||
assert prompt.template == self.MESSAGES
|
||||
assert prompt.commit is None
|
||||
assert prompt.synced is False
|
||||
|
||||
def test_sync_returns_false_on_api_error(self):
|
||||
with (
|
||||
_mock_opik_client(),
|
||||
patch(
|
||||
"opik.api_objects.prompt.client.PromptClient.create_prompt",
|
||||
side_effect=ApiError(status_code=500),
|
||||
),
|
||||
):
|
||||
prompt = ChatPrompt(name="test-chat", messages=self.MESSAGES)
|
||||
result = prompt.sync_with_backend()
|
||||
|
||||
assert result is False
|
||||
assert prompt.synced is False
|
||||
|
||||
def test_sync_returns_true_on_success(self):
|
||||
mock_version = MagicMock()
|
||||
mock_version.commit = "abc123"
|
||||
mock_version.prompt_id = "pid"
|
||||
mock_version.id = "vid"
|
||||
mock_version.change_description = None
|
||||
mock_version.tags = []
|
||||
|
||||
with (
|
||||
_mock_opik_client(),
|
||||
patch(
|
||||
"opik.api_objects.prompt.client.PromptClient.create_prompt",
|
||||
return_value=mock_version,
|
||||
),
|
||||
):
|
||||
prompt = ChatPrompt(name="test-chat", messages=self.MESSAGES)
|
||||
|
||||
assert prompt.commit == "abc123"
|
||||
assert prompt.synced is True
|
||||
|
||||
with (
|
||||
_mock_opik_client(),
|
||||
patch(
|
||||
"opik.api_objects.prompt.client.PromptClient.create_prompt",
|
||||
return_value=mock_version,
|
||||
),
|
||||
):
|
||||
assert prompt.sync_with_backend() is True
|
||||
assert prompt.synced is True
|
||||
|
||||
def test_non_api_error_propagates(self):
|
||||
with (
|
||||
_mock_opik_client(),
|
||||
patch(
|
||||
"opik.api_objects.prompt.client.PromptClient.create_prompt",
|
||||
side_effect=ValueError("bad value"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(ValueError, match="bad value"):
|
||||
ChatPrompt(name="test-chat", messages=self.MESSAGES)
|
||||
@@ -0,0 +1,138 @@
|
||||
import pytest
|
||||
from opik.api_objects.prompt import PromptTemplate, PromptType
|
||||
from opik import exceptions
|
||||
|
||||
|
||||
def test_prompt__format__happyflow():
|
||||
PROMPT_TEMPLATE = "Hi, my name is {{name}}, I live in {{city}}."
|
||||
|
||||
tested = PromptTemplate(PROMPT_TEMPLATE)
|
||||
|
||||
result = tested.format(name="Harry", city="London")
|
||||
assert result == "Hi, my name is Harry, I live in London."
|
||||
|
||||
|
||||
def test_prompt__format__one_placeholder_used_multiple_times():
|
||||
PROMPT_TEMPLATE = (
|
||||
"Hi, my name is {{name}}, I live in {{city}}. I repeat, my name is {{name}}"
|
||||
)
|
||||
|
||||
tested = PromptTemplate(PROMPT_TEMPLATE)
|
||||
|
||||
result = tested.format(name="Harry", city="London")
|
||||
assert (
|
||||
result == "Hi, my name is Harry, I live in London. I repeat, my name is Harry"
|
||||
)
|
||||
|
||||
|
||||
def test_prompt__format__passed_arguments_that_are_not_in_template__error_raised_with_correct_report_info():
|
||||
PROMPT_TEMPLATE = "Hi, my name is {{name}}, I live in {{city}}."
|
||||
|
||||
tested = PromptTemplate(PROMPT_TEMPLATE)
|
||||
|
||||
with pytest.raises(
|
||||
exceptions.PromptPlaceholdersDontMatchFormatArguments
|
||||
) as exc_info:
|
||||
tested.format(name="Harry", city="London", nemesis_name="Voldemort")
|
||||
|
||||
assert exc_info.value.format_arguments == set(["name", "city", "nemesis_name"])
|
||||
assert exc_info.value.prompt_placeholders == set(
|
||||
[
|
||||
"name",
|
||||
"city",
|
||||
]
|
||||
)
|
||||
assert exc_info.value.symmetric_difference == set(["nemesis_name"])
|
||||
|
||||
|
||||
def test_prompt__format__some_placeholders_dont_have_corresponding_format_arguments__error_raised_with_correct_report_info():
|
||||
PROMPT_TEMPLATE = "Hi, my name is {{name}}, I live in {{city}}."
|
||||
|
||||
tested = PromptTemplate(PROMPT_TEMPLATE)
|
||||
|
||||
with pytest.raises(
|
||||
exceptions.PromptPlaceholdersDontMatchFormatArguments
|
||||
) as exc_info:
|
||||
tested.format(name="Harry")
|
||||
|
||||
assert exc_info.value.format_arguments == set(["name"])
|
||||
assert exc_info.value.prompt_placeholders == set(["name", "city"])
|
||||
assert exc_info.value.symmetric_difference == set(["city"])
|
||||
|
||||
|
||||
def test_prompt__format__some_placeholders_dont_have_corresponding_format_arguments_AND_there_are_format_arguments_that_are_not_in_the_template__error_raised_with_correct_report_info():
|
||||
PROMPT_TEMPLATE = "Hi, my name is {{name}}, I live in {{city}}."
|
||||
|
||||
tested = PromptTemplate(PROMPT_TEMPLATE)
|
||||
|
||||
with pytest.raises(
|
||||
exceptions.PromptPlaceholdersDontMatchFormatArguments
|
||||
) as exc_info:
|
||||
tested.format(name="Harry", nemesis_name="Voldemort")
|
||||
|
||||
assert exc_info.value.format_arguments == set(["name", "nemesis_name"])
|
||||
assert exc_info.value.prompt_placeholders == set(["name", "city"])
|
||||
assert exc_info.value.symmetric_difference == set(["city", "nemesis_name"])
|
||||
|
||||
|
||||
def test_prompt__format_jinja2__happyflow():
|
||||
PROMPT_TEMPLATE = "Hi, my name is {{ name }}, I live in {{ city }}."
|
||||
|
||||
tested = PromptTemplate(PROMPT_TEMPLATE, type=PromptType.JINJA2)
|
||||
|
||||
result = tested.format(name="Harry", city="London")
|
||||
assert result == "Hi, my name is Harry, I live in London."
|
||||
|
||||
|
||||
def test_prompt__format_jinja2__with_control_flow():
|
||||
PROMPT_TEMPLATE = """
|
||||
{% if is_wizard %}
|
||||
{{ name }} is a wizard who lives in {{ city }}.
|
||||
{% else %}
|
||||
{{ name }} is a muggle who lives in {{ city }}.
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
tested = PromptTemplate(PROMPT_TEMPLATE, type=PromptType.JINJA2)
|
||||
|
||||
wizard_result = tested.format(name="Harry", city="London", is_wizard=True)
|
||||
assert "Harry is a wizard who lives in London." in wizard_result.strip()
|
||||
|
||||
muggle_result = tested.format(name="Dudley", city="Surrey", is_wizard=False)
|
||||
assert "Dudley is a muggle who lives in Surrey." in muggle_result.strip()
|
||||
|
||||
|
||||
def test_prompt__format_jinja2__with_loops():
|
||||
PROMPT_TEMPLATE = """
|
||||
{{ name }}'s friends are:
|
||||
{% for friend in friends %}
|
||||
- {{ friend }}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
tested = PromptTemplate(PROMPT_TEMPLATE, type=PromptType.JINJA2)
|
||||
|
||||
result = tested.format(name="Harry", friends=["Ron", "Hermione", "Neville"])
|
||||
|
||||
assert "Harry's friends are:" in result
|
||||
assert "- Ron" in result
|
||||
assert "- Hermione" in result
|
||||
assert "- Neville" in result
|
||||
|
||||
|
||||
def test_prompt__format_jinja2__with_filters():
|
||||
PROMPT_TEMPLATE = "{{ name | upper }} lives in {{ city | lower }}."
|
||||
|
||||
tested = PromptTemplate(PROMPT_TEMPLATE, type=PromptType.JINJA2)
|
||||
|
||||
result = tested.format(name="Harry", city="LONDON")
|
||||
assert result == "HARRY lives in london."
|
||||
|
||||
|
||||
def test_prompt__format__none_values_render_as_empty_strings() -> None:
|
||||
PROMPT_TEMPLATE = "Primary: {{primary}} Secondary: {{secondary}}"
|
||||
|
||||
tested = PromptTemplate(PROMPT_TEMPLATE)
|
||||
|
||||
result = tested.format(primary="cat", secondary=None)
|
||||
assert result == "Primary: cat Secondary: "
|
||||
Reference in New Issue
Block a user