chore: import upstream snapshot with attribution
Ruff / Ruff (push) Has been cancelled
Test / Core Tests (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.10) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.11) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.12) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.13) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.9) (push) Has been cancelled
Test / Full Coverage (Python 3.11) (push) Has been cancelled
Test / Core Provider Tests (OpenAI) (push) Has been cancelled
Test / Core Provider Tests (Anthropic) (push) Has been cancelled
Test / Core Provider Tests (Google) (push) Has been cancelled
Test / Core Provider Tests (Other) (push) Has been cancelled
Test / Anthropic Tests (push) Has been cancelled
Test / Gemini Tests (push) Has been cancelled
Test / Google GenAI Tests (push) Has been cancelled
Test / Vertex AI Tests (push) Has been cancelled
Test / OpenAI Tests (push) Has been cancelled
Test / Writer Tests (push) Has been cancelled
Test / Auto Client Tests (push) Has been cancelled
ty / type-check (push) Has been cancelled
Ruff / Ruff (push) Has been cancelled
Test / Core Tests (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.10) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.11) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.12) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.13) (push) Has been cancelled
Test / Offline Coverage Tests (Python 3.9) (push) Has been cancelled
Test / Full Coverage (Python 3.11) (push) Has been cancelled
Test / Core Provider Tests (OpenAI) (push) Has been cancelled
Test / Core Provider Tests (Anthropic) (push) Has been cancelled
Test / Core Provider Tests (Google) (push) Has been cancelled
Test / Core Provider Tests (Other) (push) Has been cancelled
Test / Anthropic Tests (push) Has been cancelled
Test / Gemini Tests (push) Has been cancelled
Test / Google GenAI Tests (push) Has been cancelled
Test / Vertex AI Tests (push) Has been cancelled
Test / OpenAI Tests (push) Has been cancelled
Test / Writer Tests (push) Has been cancelled
Test / Auto Client Tests (push) Has been cancelled
ty / type-check (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"""Isolated tests for Anthropic JSON parsing helpers."""
|
||||
|
||||
from anthropic.types import Message, TextBlock, Usage
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from typing import cast
|
||||
|
||||
from instructor.mode import Mode
|
||||
from instructor.processing.response import process_response
|
||||
from instructor.utils.providers import Provider
|
||||
|
||||
|
||||
CONTROL_CHAR_JSON = """{
|
||||
"data": "Claude likes
|
||||
control
|
||||
characters"
|
||||
}"""
|
||||
|
||||
|
||||
class _AnthropicTestModel(BaseModel):
|
||||
data: str
|
||||
|
||||
|
||||
def _build_message(data_content: str) -> Message:
|
||||
return Message(
|
||||
id="test_id",
|
||||
content=[TextBlock(type="text", text=data_content)],
|
||||
model="claude-3-5-haiku-20241022",
|
||||
role="assistant",
|
||||
stop_reason="end_turn",
|
||||
stop_sequence=None,
|
||||
type="message",
|
||||
usage=Usage(input_tokens=10, output_tokens=10),
|
||||
)
|
||||
|
||||
|
||||
def test_parse_anthropic_json_strict_control_characters() -> None:
|
||||
message = _build_message(CONTROL_CHAR_JSON)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
process_response(
|
||||
response=message,
|
||||
response_model=_AnthropicTestModel,
|
||||
stream=False,
|
||||
strict=True,
|
||||
mode=Mode.JSON,
|
||||
provider=Provider.ANTHROPIC,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_anthropic_json_non_strict_preserves_control_characters() -> None:
|
||||
message = _build_message(CONTROL_CHAR_JSON)
|
||||
|
||||
model = cast(
|
||||
_AnthropicTestModel,
|
||||
process_response(
|
||||
response=message,
|
||||
response_model=_AnthropicTestModel,
|
||||
stream=False,
|
||||
strict=False,
|
||||
mode=Mode.JSON,
|
||||
provider=Provider.ANTHROPIC,
|
||||
),
|
||||
)
|
||||
|
||||
assert model.data == "Claude likes\ncontrol\ncharacters"
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Benchmark tests for dictionary operations in instructor."""
|
||||
|
||||
import timeit
|
||||
from instructor.core.retry import extract_messages
|
||||
from instructor.utils import (
|
||||
combine_system_messages,
|
||||
extract_system_messages,
|
||||
update_gemini_kwargs,
|
||||
)
|
||||
|
||||
# Mock data for benchmarks
|
||||
SAMPLE_KWARGS_MESSAGES = {"messages": [{"role": "user", "content": "Hello"}]}
|
||||
SAMPLE_KWARGS_CONTENTS = {"contents": [{"role": "user", "parts": ["Hello"]}]}
|
||||
SAMPLE_KWARGS_CHAT_HISTORY = {"chat_history": [{"role": "user", "message": "Hello"}]}
|
||||
SAMPLE_KWARGS_EMPTY = {}
|
||||
|
||||
SAMPLE_SYSTEM_MSG_STR = "You are a helpful assistant."
|
||||
SAMPLE_SYSTEM_MSG_LIST = [{"type": "text", "text": "You are a helpful assistant."}]
|
||||
|
||||
SAMPLE_MESSAGES = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
SAMPLE_GEMINI_KWARGS = {
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
"max_tokens": 1000,
|
||||
"temperature": 0.7,
|
||||
"n": 1,
|
||||
"top_p": 0.9,
|
||||
"stop": ["###"],
|
||||
"generation_config": {
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestDictionaryOperations:
|
||||
"""Test suite for dictionary operations performance."""
|
||||
|
||||
def test_extract_messages_benchmark(self):
|
||||
"""Benchmark for extract_messages function."""
|
||||
# Test with different message locations
|
||||
results = {}
|
||||
|
||||
# Benchmark with messages key
|
||||
results["messages"] = timeit.timeit(
|
||||
lambda: extract_messages(SAMPLE_KWARGS_MESSAGES), number=10000
|
||||
)
|
||||
|
||||
# Benchmark with contents key
|
||||
results["contents"] = timeit.timeit(
|
||||
lambda: extract_messages(SAMPLE_KWARGS_CONTENTS), number=10000
|
||||
)
|
||||
|
||||
# Benchmark with chat_history key
|
||||
results["chat_history"] = timeit.timeit(
|
||||
lambda: extract_messages(SAMPLE_KWARGS_CHAT_HISTORY), number=10000
|
||||
)
|
||||
|
||||
# Benchmark with empty dict
|
||||
results["empty"] = timeit.timeit(
|
||||
lambda: extract_messages(SAMPLE_KWARGS_EMPTY), number=10000
|
||||
)
|
||||
|
||||
# Print benchmark results (useful for debugging)
|
||||
print("\nExtract Messages Benchmark Results:")
|
||||
for key, time in results.items():
|
||||
print(f"{key}: {time:.6f}s")
|
||||
|
||||
# Ensure the optimized version is faster than a baseline (for CI)
|
||||
baseline = 0.1 # Adjust based on initial benchmark runs
|
||||
for key, time in results.items():
|
||||
assert time < baseline, (
|
||||
f"extract_messages with {key} is too slow: {time:.6f}s > {baseline:.6f}s"
|
||||
)
|
||||
|
||||
def test_combine_system_messages_benchmark(self):
|
||||
"""Benchmark for combine_system_messages function."""
|
||||
results = {}
|
||||
|
||||
# Both string
|
||||
results["str_str"] = timeit.timeit(
|
||||
lambda: combine_system_messages(
|
||||
SAMPLE_SYSTEM_MSG_STR, SAMPLE_SYSTEM_MSG_STR
|
||||
),
|
||||
number=10000,
|
||||
)
|
||||
|
||||
# Both list
|
||||
results["list_list"] = timeit.timeit(
|
||||
lambda: combine_system_messages(
|
||||
SAMPLE_SYSTEM_MSG_LIST, SAMPLE_SYSTEM_MSG_LIST
|
||||
),
|
||||
number=10000,
|
||||
)
|
||||
|
||||
# String and list
|
||||
results["str_list"] = timeit.timeit(
|
||||
lambda: combine_system_messages(
|
||||
SAMPLE_SYSTEM_MSG_STR, SAMPLE_SYSTEM_MSG_LIST
|
||||
),
|
||||
number=10000,
|
||||
)
|
||||
|
||||
# List and string
|
||||
results["list_str"] = timeit.timeit(
|
||||
lambda: combine_system_messages(
|
||||
SAMPLE_SYSTEM_MSG_LIST, SAMPLE_SYSTEM_MSG_STR
|
||||
),
|
||||
number=10000,
|
||||
)
|
||||
|
||||
# None and string
|
||||
results["none_str"] = timeit.timeit(
|
||||
lambda: combine_system_messages(None, SAMPLE_SYSTEM_MSG_STR),
|
||||
number=10000,
|
||||
)
|
||||
|
||||
print("\nCombine System Messages Benchmark Results:")
|
||||
for key, time in results.items():
|
||||
print(f"{key}: {time:.6f}s")
|
||||
|
||||
baseline = 0.2 # Adjust based on initial benchmark runs
|
||||
for key, time in results.items():
|
||||
assert time < baseline, (
|
||||
f"combine_system_messages with {key} is too slow: {time:.6f}s > {baseline:.6f}s"
|
||||
)
|
||||
|
||||
def test_extract_system_messages_benchmark(self):
|
||||
"""Benchmark for extract_system_messages function."""
|
||||
results = {}
|
||||
|
||||
# With system messages
|
||||
results["with_system"] = timeit.timeit(
|
||||
lambda: extract_system_messages(SAMPLE_MESSAGES),
|
||||
number=10000,
|
||||
)
|
||||
|
||||
# Without system messages
|
||||
results["no_system"] = timeit.timeit(
|
||||
lambda: extract_system_messages([{"role": "user", "content": "Hello"}]),
|
||||
number=10000,
|
||||
)
|
||||
|
||||
# Empty messages
|
||||
results["empty"] = timeit.timeit(
|
||||
lambda: extract_system_messages([]),
|
||||
number=10000,
|
||||
)
|
||||
|
||||
print("\nExtract System Messages Benchmark Results:")
|
||||
for key, time in results.items():
|
||||
print(f"{key}: {time:.6f}s")
|
||||
|
||||
baseline = 0.2 # Adjust based on initial benchmark runs
|
||||
for key, time in results.items():
|
||||
assert time < baseline, (
|
||||
f"extract_system_messages with {key} is too slow: {time:.6f}s > {baseline:.6f}s"
|
||||
)
|
||||
|
||||
def test_update_gemini_kwargs_benchmark(self):
|
||||
"""Benchmark for update_gemini_kwargs function."""
|
||||
result = timeit.timeit(
|
||||
lambda: update_gemini_kwargs(SAMPLE_GEMINI_KWARGS),
|
||||
number=1000,
|
||||
)
|
||||
|
||||
print(f"\nUpdate Gemini Kwargs Benchmark Result: {result:.6f}s")
|
||||
baseline = 0.2 # Adjust based on initial benchmark runs
|
||||
assert result < baseline, (
|
||||
f"update_gemini_kwargs is too slow: {result:.6f}s > {baseline:.6f}s"
|
||||
)
|
||||
|
||||
# We'll use a simpler test for mode lookup patterns since proper mocking is complex
|
||||
# Test removed as it was producing inconsistent results across different environments
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests to validate that the optimized dictionary operations provide the same results as before."""
|
||||
|
||||
from instructor.core.retry import extract_messages
|
||||
from instructor.utils import (
|
||||
combine_system_messages,
|
||||
extract_system_messages,
|
||||
update_gemini_kwargs,
|
||||
SystemMessage,
|
||||
)
|
||||
|
||||
|
||||
class TestDictOperationsValidation:
|
||||
"""Test suite for validating dictionary operations behavior."""
|
||||
|
||||
def test_extract_messages_validation(self):
|
||||
"""Validate extract_messages returns the same results after optimization."""
|
||||
# Test with messages key
|
||||
sample_messages = [{"role": "user", "content": "Hello"}]
|
||||
kwargs = {"messages": sample_messages}
|
||||
result = extract_messages(kwargs)
|
||||
assert result == sample_messages
|
||||
|
||||
# Test with contents key
|
||||
sample_contents = [{"role": "user", "parts": ["Hello"]}]
|
||||
kwargs = {"contents": sample_contents}
|
||||
result = extract_messages(kwargs)
|
||||
assert result == sample_contents
|
||||
|
||||
# Test with chat_history key
|
||||
sample_chat_history = [{"role": "user", "message": "Hello"}]
|
||||
kwargs = {"chat_history": sample_chat_history}
|
||||
result = extract_messages(kwargs)
|
||||
assert result == sample_chat_history
|
||||
|
||||
# Test with empty dict
|
||||
kwargs = {}
|
||||
result = extract_messages(kwargs)
|
||||
assert result == []
|
||||
|
||||
# Test with mixed keys (should prioritize messages)
|
||||
kwargs = {
|
||||
"messages": sample_messages,
|
||||
"contents": sample_contents,
|
||||
"chat_history": sample_chat_history,
|
||||
}
|
||||
result = extract_messages(kwargs)
|
||||
assert result == sample_messages
|
||||
|
||||
def test_combine_system_messages_validation(self):
|
||||
"""Validate combine_system_messages returns the same results after optimization."""
|
||||
# Test with both strings
|
||||
existing = "You are a helpful assistant."
|
||||
new = "You should be concise."
|
||||
expected = "You are a helpful assistant.\n\nYou should be concise."
|
||||
result = combine_system_messages(existing, new)
|
||||
assert result == expected
|
||||
|
||||
# Test with both lists
|
||||
existing_list = [
|
||||
SystemMessage(type="text", text="You are a helpful assistant.")
|
||||
]
|
||||
new_list = [SystemMessage(type="text", text="You should be concise.")]
|
||||
result = combine_system_messages(existing_list, new_list)
|
||||
assert len(result) == 2
|
||||
assert result[0]["text"] == "You are a helpful assistant."
|
||||
assert result[1]["text"] == "You should be concise."
|
||||
|
||||
# Test with existing string, new list
|
||||
result = combine_system_messages(existing, new_list)
|
||||
assert len(result) == 2
|
||||
assert result[0]["text"] == "You are a helpful assistant."
|
||||
assert result[1]["text"] == "You should be concise."
|
||||
|
||||
# Test with existing list, new string
|
||||
result = combine_system_messages(existing_list, new)
|
||||
assert len(result) == 2
|
||||
assert result[0]["text"] == "You are a helpful assistant."
|
||||
assert result[1]["text"] == "You should be concise."
|
||||
|
||||
# Test with None existing
|
||||
result = combine_system_messages(None, new)
|
||||
assert result == new
|
||||
|
||||
result = combine_system_messages(None, new_list)
|
||||
assert result == new_list
|
||||
|
||||
def test_extract_system_messages_validation(self):
|
||||
"""Validate extract_system_messages returns the same results after optimization."""
|
||||
# Test with system messages
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
result = extract_system_messages(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["text"] == "You are a helpful assistant."
|
||||
|
||||
# Test with multiple system messages
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "system", "content": "You should be concise."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
result = extract_system_messages(messages)
|
||||
assert len(result) == 2
|
||||
assert result[0]["text"] == "You are a helpful assistant."
|
||||
assert result[1]["text"] == "You should be concise."
|
||||
|
||||
# Test with no system messages
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
result = extract_system_messages(messages)
|
||||
assert result == []
|
||||
|
||||
# Test with empty messages
|
||||
result = extract_system_messages([])
|
||||
assert result == []
|
||||
|
||||
# Test with system message and list content
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "You are a helpful assistant."}],
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
result = extract_system_messages(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["text"] == "You are a helpful assistant."
|
||||
|
||||
def test_update_gemini_kwargs_validation(self):
|
||||
"""Validate update_gemini_kwargs returns the same results after optimization."""
|
||||
# Test with complete kwargs
|
||||
kwargs = {
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
"max_tokens": 1000,
|
||||
"temperature": 0.7,
|
||||
"generation_config": {
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.9,
|
||||
"n": 1,
|
||||
"stop": ["###"],
|
||||
},
|
||||
}
|
||||
|
||||
result = update_gemini_kwargs(kwargs)
|
||||
|
||||
# Check that it contains contents transformed from messages
|
||||
assert "contents" in result
|
||||
assert (
|
||||
len(result["contents"]) == 1
|
||||
) # System messages are merged into first user message
|
||||
|
||||
# Check that generation_config was updated properly
|
||||
assert "max_output_tokens" in result["generation_config"]
|
||||
assert result["generation_config"]["max_output_tokens"] == 2000
|
||||
assert "candidate_count" in result["generation_config"]
|
||||
assert result["generation_config"]["candidate_count"] == 1
|
||||
assert "stop_sequences" in result["generation_config"]
|
||||
assert result["generation_config"]["stop_sequences"] == ["###"]
|
||||
|
||||
# Check that safety settings were added
|
||||
assert "safety_settings" in result
|
||||
|
||||
# Ensure the original kwargs wasn't modified
|
||||
assert "contents" not in kwargs
|
||||
assert "messages" in kwargs
|
||||
@@ -0,0 +1,57 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, create_model, Field
|
||||
from instructor import openai_schema
|
||||
|
||||
|
||||
def test_dynamic_model_creation_with_field_description():
|
||||
"""
|
||||
Test that dynamic model creation with Field(description) works correctly.
|
||||
This verifies the example in the documentation at docs/concepts/models.md.
|
||||
"""
|
||||
types: dict[str, type[Any]] = {
|
||||
"string": str,
|
||||
"integer": int,
|
||||
"email": str,
|
||||
}
|
||||
|
||||
mock_cursor = [
|
||||
("name", "string", "The name of the user."),
|
||||
("age", "integer", "The age of the user."),
|
||||
("email", "email", "The email of the user."),
|
||||
]
|
||||
|
||||
field_definitions: dict[str, Any] = {
|
||||
property_name: (types[property_type], Field(description=description))
|
||||
for property_name, property_type, description in mock_cursor
|
||||
}
|
||||
DynamicModel = create_model(
|
||||
"User",
|
||||
__base__=BaseModel,
|
||||
**field_definitions,
|
||||
)
|
||||
|
||||
schema = DynamicModel.model_json_schema()
|
||||
|
||||
assert schema["properties"]["name"]["description"] == "The name of the user."
|
||||
assert schema["properties"]["age"]["description"] == "The age of the user."
|
||||
assert schema["properties"]["email"]["description"] == "The email of the user."
|
||||
|
||||
assert "default" not in schema["properties"]["name"]
|
||||
assert "default" not in schema["properties"]["age"]
|
||||
assert "default" not in schema["properties"]["email"]
|
||||
|
||||
OpenAISchemaModel = openai_schema(DynamicModel)
|
||||
openai_schema_json = OpenAISchemaModel.model_json_schema()
|
||||
|
||||
assert (
|
||||
openai_schema_json["properties"]["name"]["description"]
|
||||
== "The name of the user."
|
||||
)
|
||||
assert (
|
||||
openai_schema_json["properties"]["age"]["description"] == "The age of the user."
|
||||
)
|
||||
assert (
|
||||
openai_schema_json["properties"]["email"]["description"]
|
||||
== "The email of the user."
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
import pytest
|
||||
from jinja2.exceptions import SecurityError
|
||||
from instructor.templating import handle_templating
|
||||
from instructor import Mode
|
||||
|
||||
|
||||
def test_handle_insecure_template():
|
||||
with pytest.raises(SecurityError):
|
||||
kwargs = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "{{ self.__init__.__globals__.__builtins__.__import__('os').system('ls') }} {{ variable }}",
|
||||
}
|
||||
]
|
||||
}
|
||||
context = {"variable": "test"}
|
||||
handle_templating(kwargs, Mode.TOOLS, context)
|
||||
|
||||
|
||||
def test_handle_templating_with_context():
|
||||
kwargs = {"messages": [{"role": "user", "content": "Hello {{ name }}!"}]}
|
||||
context = {"name": "Alice"}
|
||||
|
||||
result = handle_templating(kwargs, Mode.TOOLS, context)
|
||||
|
||||
assert result == {"messages": [{"role": "user", "content": "Hello Alice!"}]}
|
||||
|
||||
|
||||
def test_handle_templating_without_context():
|
||||
kwargs = {"messages": [{"role": "user", "content": "Hello {{ name }}!"}]}
|
||||
|
||||
result = handle_templating(kwargs, Mode.TOOLS)
|
||||
|
||||
assert result == kwargs
|
||||
|
||||
|
||||
def test_handle_templating_with_anthropic_format():
|
||||
kwargs = {
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "Hello {{ name }}!"}]}
|
||||
]
|
||||
}
|
||||
context = {"name": "Bob"}
|
||||
|
||||
result = handle_templating(kwargs, Mode.TOOLS, context)
|
||||
|
||||
assert result == {
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "Hello Bob!"}]}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_handle_templating_with_mixed_content():
|
||||
kwargs = {
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello {{ name }}!"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Nice to meet you, {{ name }}!"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
context = {"name": "Charlie"}
|
||||
|
||||
result = handle_templating(kwargs, Mode.TOOLS, context)
|
||||
|
||||
assert result == {
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello Charlie!"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Nice to meet you, Charlie!"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_handle_templating_with_secret_context():
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
class UserContext(BaseModel):
|
||||
name: str
|
||||
address: SecretStr
|
||||
|
||||
kwargs = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "{{ user.name }}'s address is '{{ user.address.get_secret_value() }}'",
|
||||
}
|
||||
]
|
||||
}
|
||||
context = {
|
||||
"user": UserContext(
|
||||
name="Jason", address=SecretStr("123 Secret St, Hidden City")
|
||||
)
|
||||
}
|
||||
|
||||
result = handle_templating(kwargs, Mode.TOOLS, context)
|
||||
|
||||
assert result == {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Jason's address is '123 Secret St, Hidden City'",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Ensure the original SecretStr is not exposed when rendered
|
||||
assert str(context["user"].address) == "**********"
|
||||
|
||||
|
||||
def test_handle_templating_with_cohere_format():
|
||||
kwargs = {
|
||||
"message": "Hello {{ name }}!",
|
||||
"chat_history": [{"message": "Previous message to {{ name }}"}],
|
||||
}
|
||||
context = {"name": "David"}
|
||||
|
||||
result = handle_templating(kwargs, Mode.TOOLS, context)
|
||||
|
||||
assert result == {
|
||||
"message": "Hello David!",
|
||||
"chat_history": [{"message": "Previous message to David"}],
|
||||
}
|
||||
|
||||
|
||||
def test_handle_templating_with_gemini_format():
|
||||
kwargs = {
|
||||
"contents": [
|
||||
{"role": "user", "parts": ["Hello {{ name }}!", "How are you {{ name }}?"]}
|
||||
]
|
||||
}
|
||||
context = {"name": "Eve"}
|
||||
|
||||
result = handle_templating(kwargs, Mode.TOOLS, context)
|
||||
|
||||
assert result == {
|
||||
"contents": [{"role": "user", "parts": ["Hello Eve!", "How are you Eve?"]}]
|
||||
}
|
||||
|
||||
|
||||
def test_handle_templating_with_genai_multimodal_parts():
|
||||
"""Non-text Parts (images/URIs) have text=None and must pass through untouched.
|
||||
|
||||
Regression test for #2253: apply_template was being called with None,
|
||||
crashing Jinja2 with "Can't compile non template nodes".
|
||||
"""
|
||||
from google.genai import types
|
||||
|
||||
text_part = types.Part.from_text(text="Describe {{ subject }}")
|
||||
image_part = types.Part.from_uri(
|
||||
file_uri="gs://example/cat.png", mime_type="image/png"
|
||||
)
|
||||
content = types.Content(role="user", parts=[text_part, image_part])
|
||||
|
||||
kwargs = {"contents": [content]}
|
||||
context = {"subject": "this image"}
|
||||
|
||||
result = handle_templating(kwargs, Mode.GENAI_TOOLS, context)
|
||||
|
||||
processed = result["contents"][0]
|
||||
assert processed.parts[0].text == "Describe this image"
|
||||
assert processed.parts[1].text is None
|
||||
assert processed.parts[1].file_data.file_uri == "gs://example/cat.png"
|
||||
@@ -0,0 +1,371 @@
|
||||
from typing import Any, TypeVar, cast
|
||||
import pytest
|
||||
from anthropic.types import Message, TextBlock, Usage
|
||||
from openai.types.chat.chat_completion import ChatCompletion, Choice
|
||||
from openai.types.chat.chat_completion_message import ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion_message import FunctionCall as OpenAIFunctionCall
|
||||
from openai.types.chat.chat_completion_message_tool_call import (
|
||||
ChatCompletionMessageToolCall,
|
||||
Function,
|
||||
)
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
import instructor
|
||||
from instructor import ResponseSchema, response_schema, OpenAISchema, openai_schema
|
||||
from instructor.core.exceptions import IncompleteOutputException
|
||||
from instructor.utils import disable_pydantic_error_url
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class _FunctionCallTestModel(ResponseSchema): # type: ignore[misc]
|
||||
name: str = "TestModel"
|
||||
data: str
|
||||
|
||||
|
||||
@pytest.fixture # type: ignore[misc]
|
||||
def test_model() -> type[_FunctionCallTestModel]:
|
||||
return _FunctionCallTestModel
|
||||
|
||||
|
||||
@pytest.fixture # type: ignore[misc]
|
||||
def mock_completion(request: Any) -> ChatCompletion:
|
||||
finish_reason = "stop"
|
||||
data_content = '{\n"data": "complete data"\n}'
|
||||
|
||||
if hasattr(request, "param"):
|
||||
params = cast(dict[str, Any], request.param)
|
||||
finish_reason = params.get("finish_reason", finish_reason)
|
||||
data_content = params.get("data_content", data_content)
|
||||
|
||||
completion = ChatCompletion(
|
||||
id="test_id",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
role="assistant",
|
||||
content=data_content,
|
||||
function_call=OpenAIFunctionCall(
|
||||
name="TestModel",
|
||||
arguments=data_content,
|
||||
),
|
||||
),
|
||||
finish_reason=finish_reason,
|
||||
logprobs=None,
|
||||
)
|
||||
],
|
||||
created=1234567890,
|
||||
model="gpt-4.1-mini",
|
||||
object="chat.completion",
|
||||
)
|
||||
|
||||
return completion
|
||||
|
||||
|
||||
@pytest.fixture # type: ignore[misc]
|
||||
def mock_anthropic_message(request: Any) -> Message:
|
||||
data_content = '{\n"data": "Claude says hi"\n}'
|
||||
if hasattr(request, "param"):
|
||||
params = cast(dict[str, Any], request.param)
|
||||
data_content = params.get("data_content", data_content)
|
||||
return Message(
|
||||
id="test_id",
|
||||
content=[TextBlock(type="text", text=data_content)],
|
||||
model="claude-3-5-haiku-20241022",
|
||||
role="assistant",
|
||||
stop_reason="end_turn",
|
||||
stop_sequence=None,
|
||||
type="message",
|
||||
usage=Usage(
|
||||
input_tokens=100,
|
||||
output_tokens=100,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_response_schema() -> None:
|
||||
@response_schema
|
||||
class Dataframe(BaseModel): # type: ignore[misc]
|
||||
"""
|
||||
Class representing a dataframe. This class is used to convert
|
||||
data into a frame that can be used by pandas.
|
||||
"""
|
||||
|
||||
data: str
|
||||
columns: str
|
||||
|
||||
def to_pandas(self) -> None:
|
||||
pass
|
||||
|
||||
assert hasattr(Dataframe, "openai_schema")
|
||||
assert hasattr(Dataframe, "from_response")
|
||||
assert hasattr(Dataframe, "to_pandas")
|
||||
assert Dataframe.openai_schema["name"] == "Dataframe"
|
||||
|
||||
|
||||
def test_response_schema_raises_error() -> None:
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match="response_model must be a subclass of pydantic.BaseModel",
|
||||
):
|
||||
|
||||
@response_schema # ty: ignore[invalid-argument-type]
|
||||
class Dummy:
|
||||
pass
|
||||
|
||||
|
||||
def test_openai_schema_alias() -> None:
|
||||
"""Test that OpenAISchema alias still works for backward compatibility."""
|
||||
|
||||
@openai_schema
|
||||
class Dataframe(BaseModel): # type: ignore[misc]
|
||||
"""
|
||||
Class representing a dataframe. This class is used to convert
|
||||
data into a frame that can be used by pandas.
|
||||
"""
|
||||
|
||||
data: str
|
||||
columns: str
|
||||
|
||||
assert hasattr(Dataframe, "openai_schema")
|
||||
assert hasattr(Dataframe, "from_response")
|
||||
assert Dataframe.openai_schema["name"] == "Dataframe"
|
||||
|
||||
|
||||
def test_openai_schema_alias_raises_error() -> None:
|
||||
"""Test that openai_schema alias still works for backward compatibility."""
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match="response_model must be a subclass of pydantic.BaseModel",
|
||||
):
|
||||
|
||||
@openai_schema # ty: ignore[invalid-argument-type]
|
||||
class Dummy:
|
||||
pass
|
||||
|
||||
|
||||
def test_no_docstring() -> None:
|
||||
class Dummy(ResponseSchema): # type: ignore[misc]
|
||||
attr: str
|
||||
|
||||
|
||||
def test_openai_schema_backward_compat() -> None:
|
||||
"""Test that OpenAISchema alias still works for backward compatibility."""
|
||||
|
||||
class Dummy(OpenAISchema): # type: ignore[misc]
|
||||
attr: str
|
||||
|
||||
assert (
|
||||
Dummy.openai_schema["description"]
|
||||
== "Correctly extracted `Dummy` with all the required parameters with correct types"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_completion",
|
||||
[{"finish_reason": "length", "data_content": '{\n"data": "incomplete dat"\n}'}],
|
||||
indirect=True,
|
||||
) # type: ignore[misc]
|
||||
def test_incomplete_output_exception(
|
||||
test_model: type[_FunctionCallTestModel], mock_completion: ChatCompletion
|
||||
) -> None:
|
||||
with pytest.raises(IncompleteOutputException):
|
||||
test_model.from_response(mock_completion, mode=instructor.Mode.FUNCTIONS)
|
||||
|
||||
|
||||
def test_complete_output_no_exception(
|
||||
test_model: type[_FunctionCallTestModel], mock_completion: ChatCompletion
|
||||
) -> None:
|
||||
test_model_instance = test_model.from_response(
|
||||
mock_completion, mode=instructor.Mode.FUNCTIONS
|
||||
)
|
||||
assert test_model_instance.data == "complete data"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_completion",
|
||||
[{"finish_reason": "length", "data_content": '{\n"data": "incomplete dat"\n}'}],
|
||||
indirect=True,
|
||||
) # type: ignore[misc]
|
||||
def test_incomplete_output_exception_raise(
|
||||
test_model: type[_FunctionCallTestModel], mock_completion: ChatCompletion
|
||||
) -> None:
|
||||
with pytest.raises(IncompleteOutputException):
|
||||
test_model.from_response(mock_completion, mode=instructor.Mode.TOOLS)
|
||||
|
||||
|
||||
def test_anthropic_no_exception(
|
||||
test_model: type[_FunctionCallTestModel], mock_anthropic_message: Message
|
||||
) -> None:
|
||||
test_model_instance = test_model.from_response(
|
||||
cast(Any, mock_anthropic_message),
|
||||
mode=instructor.Mode.ANTHROPIC_JSON,
|
||||
)
|
||||
assert test_model_instance.data == "Claude says hi"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_anthropic_message",
|
||||
[{"data_content": '{\n"data": "Claude likes\ncontrol\ncharacters"\n}'}],
|
||||
indirect=True,
|
||||
) # type: ignore[misc]
|
||||
def test_control_characters_not_allowed_in_anthropic_json_strict_mode(
|
||||
test_model: type[_FunctionCallTestModel], mock_anthropic_message: Message
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
test_model.from_response(
|
||||
cast(Any, mock_anthropic_message),
|
||||
mode=instructor.Mode.ANTHROPIC_JSON,
|
||||
strict=True,
|
||||
)
|
||||
|
||||
# https://docs.pydantic.dev/latest/errors/validation_errors/#json_invalid
|
||||
exc = exc_info.value
|
||||
assert len(exc.errors()) == 1
|
||||
assert exc.errors()[0]["type"] == "json_invalid"
|
||||
assert "control character" in exc.errors()[0]["msg"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_anthropic_message",
|
||||
[{"data_content": '{\n"data": "Claude likes\ncontrol\ncharacters"\n}'}],
|
||||
indirect=True,
|
||||
) # type: ignore[misc]
|
||||
def test_control_characters_allowed_in_anthropic_json_non_strict_mode(
|
||||
test_model: type[_FunctionCallTestModel], mock_anthropic_message: Message
|
||||
) -> None:
|
||||
test_model_instance = test_model.from_response(
|
||||
cast(Any, mock_anthropic_message),
|
||||
mode=instructor.Mode.ANTHROPIC_JSON,
|
||||
strict=False,
|
||||
)
|
||||
assert test_model_instance.data == "Claude likes\ncontrol\ncharacters"
|
||||
|
||||
|
||||
def test_pylance_url_config() -> None:
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
reason = (
|
||||
"This test seems to fail on 3.11 but passes on 3.10 and 3.9. I "
|
||||
"suspect it's due to the ordering of tests - "
|
||||
"https://github.com/pydantic/pydantic-core/blob/"
|
||||
"e3eff5cb8a6dae8914e3831b00c690d9dee4b740/python/pydantic_core/"
|
||||
"_pydantic_core.pyi#L820C9-L829C12"
|
||||
)
|
||||
raise pytest.skip.Exception(reason)
|
||||
|
||||
class Model(BaseModel):
|
||||
list_of_ints: list[int]
|
||||
a_float: float
|
||||
|
||||
disable_pydantic_error_url()
|
||||
data = dict(list_of_ints=["1", 2, "bad"], a_float="Not a float")
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Model.model_validate(data)
|
||||
|
||||
assert "https://errors.pydantic.dev" not in str(exc_info.value)
|
||||
|
||||
|
||||
def test_refusal_attribute(test_model: type[_FunctionCallTestModel]):
|
||||
completion = ChatCompletion(
|
||||
id="test_id",
|
||||
created=1234567890,
|
||||
model="gpt-4.1-mini",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content="test_content",
|
||||
refusal="test_refusal",
|
||||
role="assistant",
|
||||
tool_calls=[],
|
||||
),
|
||||
finish_reason="stop",
|
||||
logprobs=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
test_model.from_response(completion, mode=instructor.Mode.TOOLS)
|
||||
except Exception as e:
|
||||
assert "Unable to generate a response due to test_refusal" in str(e)
|
||||
|
||||
|
||||
def test_no_refusal_attribute(test_model: type[_FunctionCallTestModel]):
|
||||
completion = ChatCompletion(
|
||||
id="test_id",
|
||||
created=1234567890,
|
||||
model="gpt-4.1-mini",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content="test_content",
|
||||
refusal=None,
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
ChatCompletionMessageToolCall(
|
||||
id="test_id",
|
||||
function=Function(
|
||||
name="TestModel",
|
||||
arguments='{"data": "test_data", "name": "TestModel"}',
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
),
|
||||
finish_reason="stop",
|
||||
logprobs=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
resp = test_model.from_response(completion, mode=instructor.Mode.TOOLS)
|
||||
assert resp.data == "test_data"
|
||||
assert resp.name == "TestModel"
|
||||
|
||||
|
||||
def test_missing_refusal_attribute(test_model: type[_FunctionCallTestModel]):
|
||||
message_without_refusal_attribute = ChatCompletionMessage(
|
||||
content="test_content",
|
||||
refusal="test_refusal",
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
ChatCompletionMessageToolCall(
|
||||
id="test_id",
|
||||
function=Function(
|
||||
name="TestModel",
|
||||
arguments='{"data": "test_data", "name": "TestModel"}',
|
||||
),
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
del message_without_refusal_attribute.refusal
|
||||
assert not hasattr(message_without_refusal_attribute, "refusal")
|
||||
|
||||
completion = ChatCompletion(
|
||||
id="test_id",
|
||||
created=1234567890,
|
||||
model="gpt-4.1-mini",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choice(
|
||||
index=0,
|
||||
message=message_without_refusal_attribute,
|
||||
finish_reason="stop",
|
||||
logprobs=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
resp = test_model.from_response(completion, mode=instructor.Mode.TOOLS)
|
||||
assert resp.data == "test_data"
|
||||
assert resp.name == "TestModel"
|
||||
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Tests for JSON extraction functionality.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from typing import cast
|
||||
|
||||
from instructor.utils import extract_json_from_codeblock, extract_json_from_stream
|
||||
from instructor.processing.function_calls import (
|
||||
_extract_text_content,
|
||||
_validate_model_from_json,
|
||||
ResponseSchema,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Person(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
skills: list[str] = []
|
||||
|
||||
|
||||
class TestJSONExtraction:
|
||||
"""Test the improved JSON extraction functionality."""
|
||||
|
||||
def test_extract_from_codeblock(self):
|
||||
"""Test extracting JSON from markdown code blocks."""
|
||||
# JSON inside markdown code block
|
||||
markdown_json = """
|
||||
# Test Data
|
||||
Here is some data:
|
||||
```json
|
||||
{
|
||||
"name": "John",
|
||||
"age": 30,
|
||||
"skills": ["python", "javascript"]
|
||||
}
|
||||
```
|
||||
More text here.
|
||||
"""
|
||||
|
||||
result = extract_json_from_codeblock(markdown_json)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["name"] == "John"
|
||||
assert parsed["age"] == 30
|
||||
assert "python" in parsed["skills"]
|
||||
|
||||
def test_extract_from_codeblock_no_language(self):
|
||||
"""Test extracting JSON from code blocks without language specified."""
|
||||
# JSON inside unmarked code block
|
||||
markdown_json = """
|
||||
# Test Data
|
||||
Here is some data:
|
||||
```
|
||||
{
|
||||
"name": "Jane",
|
||||
"age": 25,
|
||||
"skills": ["java", "typescript"]
|
||||
}
|
||||
```
|
||||
More text here.
|
||||
"""
|
||||
|
||||
result = extract_json_from_codeblock(markdown_json)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["name"] == "Jane"
|
||||
assert parsed["age"] == 25
|
||||
assert "java" in parsed["skills"]
|
||||
|
||||
def test_extract_plain_json(self):
|
||||
"""Test extracting JSON without code blocks."""
|
||||
# Plain JSON with surrounding text
|
||||
plain_json = """
|
||||
Here is the user information:
|
||||
{
|
||||
"name": "Bob",
|
||||
"age": 40,
|
||||
"skills": ["go", "rust"]
|
||||
}
|
||||
End of data.
|
||||
"""
|
||||
|
||||
result = extract_json_from_codeblock(plain_json)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["name"] == "Bob"
|
||||
assert parsed["age"] == 40
|
||||
assert "rust" in parsed["skills"]
|
||||
|
||||
def test_nested_json(self):
|
||||
"""Test extracting nested JSON objects."""
|
||||
# Nested JSON
|
||||
nested_json = """
|
||||
```json
|
||||
{
|
||||
"name": "Alice",
|
||||
"age": 35,
|
||||
"address": {
|
||||
"street": "123 Main St",
|
||||
"city": "Anytown",
|
||||
"zip": "12345"
|
||||
},
|
||||
"skills": ["python", "ml"]
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
result = extract_json_from_codeblock(nested_json)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["name"] == "Alice"
|
||||
assert parsed["address"]["city"] == "Anytown"
|
||||
assert "ml" in parsed["skills"]
|
||||
|
||||
def test_json_with_arrays(self):
|
||||
"""Test extracting JSON with arrays."""
|
||||
# JSON with arrays
|
||||
array_json = """
|
||||
Here's an array of users:
|
||||
{
|
||||
"users": [
|
||||
{"name": "User1", "age": 20},
|
||||
{"name": "User2", "age": 30},
|
||||
{"name": "User3", "age": 40}
|
||||
],
|
||||
"total": 3
|
||||
}
|
||||
"""
|
||||
|
||||
result = extract_json_from_codeblock(array_json)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert len(parsed["users"]) == 3
|
||||
assert parsed["users"][0]["name"] == "User1"
|
||||
assert parsed["total"] == 3
|
||||
|
||||
def test_invalid_json(self):
|
||||
"""Test handling of invalid JSON."""
|
||||
# Invalid JSON
|
||||
invalid_json = """
|
||||
This is not valid JSON:
|
||||
{ name: "Test" }
|
||||
"""
|
||||
|
||||
result = extract_json_from_codeblock(invalid_json)
|
||||
# Should return the content between braces even if it's invalid
|
||||
assert "{" in result and "}" in result
|
||||
|
||||
# Should raise when trying to parse
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
json.loads(result)
|
||||
|
||||
def test_extract_from_stream(self):
|
||||
"""Test extracting JSON from a stream of chunks."""
|
||||
# JSON split into chunks
|
||||
chunks = [
|
||||
'{"na',
|
||||
'me": "',
|
||||
"Stream",
|
||||
'User", ',
|
||||
'"age": 45, "sk',
|
||||
'ills": ["stream',
|
||||
'ing", "json"]}',
|
||||
]
|
||||
|
||||
collected = "".join(extract_json_from_stream(chunks))
|
||||
parsed = json.loads(collected)
|
||||
|
||||
assert parsed["name"] == "StreamUser"
|
||||
assert parsed["age"] == 45
|
||||
assert "streaming" in parsed["skills"]
|
||||
|
||||
|
||||
class TestTextExtraction:
|
||||
"""Test the text extraction utilities."""
|
||||
|
||||
def test_extract_text_openai_format(self):
|
||||
"""Test extracting text from OpenAI completion format."""
|
||||
|
||||
class MockMessage:
|
||||
content = "Sample content"
|
||||
|
||||
class MockChoice:
|
||||
message = MockMessage()
|
||||
|
||||
class MockCompletion:
|
||||
choices = [MockChoice()]
|
||||
|
||||
completion = MockCompletion()
|
||||
result = _extract_text_content(completion)
|
||||
|
||||
assert result == "Sample content"
|
||||
|
||||
def test_extract_text_simple_format(self):
|
||||
"""Test extracting text from simple text format."""
|
||||
|
||||
class MockCompletion:
|
||||
text = "Simple text response"
|
||||
|
||||
completion = MockCompletion()
|
||||
result = _extract_text_content(completion)
|
||||
|
||||
assert result == "Simple text response"
|
||||
|
||||
def test_extract_text_anthropic_format(self):
|
||||
"""Test extracting text from Anthropic format."""
|
||||
|
||||
class MockTextBlock:
|
||||
def __init__(self, text_content):
|
||||
self.type = "text"
|
||||
self.text = text_content
|
||||
|
||||
class MockCompletion:
|
||||
content = [
|
||||
MockTextBlock("Anthropic response"),
|
||||
MockTextBlock("Additional text"),
|
||||
]
|
||||
|
||||
completion = MockCompletion()
|
||||
result = _extract_text_content(completion)
|
||||
|
||||
assert result == "Anthropic response"
|
||||
|
||||
def test_extract_text_bedrock_format(self):
|
||||
"""Test extracting text from Bedrock format."""
|
||||
completion = {
|
||||
"output": {"message": {"content": [{"text": "Bedrock response"}]}}
|
||||
}
|
||||
|
||||
result = _extract_text_content(completion)
|
||||
assert result == "Bedrock response"
|
||||
|
||||
def test_extract_text_unknown_format(self):
|
||||
"""Test extracting text from unknown format."""
|
||||
|
||||
class UnknownFormat:
|
||||
unknown_field = "Can't extract this"
|
||||
|
||||
completion = UnknownFormat()
|
||||
result = _extract_text_content(completion)
|
||||
|
||||
# Should return empty string for unknown formats
|
||||
assert result == ""
|
||||
|
||||
|
||||
class TestModelValidation:
|
||||
"""Test the model validation utilities."""
|
||||
|
||||
def test_validate_model_strict(self):
|
||||
"""Test model validation with strict mode."""
|
||||
json_str = '{"name": "ValidUser", "age": 30, "skills": ["coding"]}'
|
||||
result = _validate_model_from_json(Person, json_str, None, True)
|
||||
|
||||
assert result.name == "ValidUser"
|
||||
assert result.age == 30
|
||||
assert result.skills == ["coding"]
|
||||
|
||||
def test_validate_model_non_strict(self):
|
||||
"""Test model validation with non-strict mode."""
|
||||
# In non-strict mode, string numbers can be coerced to integers
|
||||
json_str = '{"name": "NonStrictUser", "age": "25", "skills": ["testing"]}'
|
||||
result = _validate_model_from_json(Person, json_str, None, False)
|
||||
|
||||
assert result.name == "NonStrictUser"
|
||||
assert result.age == 25 # String "25" coerced to integer
|
||||
assert result.skills == ["testing"]
|
||||
|
||||
def test_validate_model_json_error(self):
|
||||
"""Test handling JSON decode errors.
|
||||
|
||||
In strict mode, Pydantic raises ValidationError with an 'Invalid JSON' message.
|
||||
"""
|
||||
invalid_json = '{"name": "Invalid, "age": 20}' # Missing quote
|
||||
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
_validate_model_from_json(Person, invalid_json, None, True)
|
||||
assert "Invalid JSON" in str(excinfo.value)
|
||||
|
||||
def test_validate_model_json_error_non_strict(self):
|
||||
"""In non-strict mode, json.loads should raise JSONDecodeError (not wrapped)."""
|
||||
invalid_json = '{"name": "Invalid, "age": 20}' # Missing quote
|
||||
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
_validate_model_from_json(Person, invalid_json, None, False)
|
||||
|
||||
|
||||
class PersonSchema(ResponseSchema):
|
||||
"""Test model that inherits from ResponseSchema."""
|
||||
|
||||
name: str
|
||||
age: int
|
||||
skills: list[str] = []
|
||||
|
||||
|
||||
class TestBedrockJSONParsing:
|
||||
"""Test the parse_bedrock_json functionality."""
|
||||
|
||||
def test_parse_bedrock_json_simple(self):
|
||||
"""Test parsing Bedrock JSON with simple text content."""
|
||||
completion = {
|
||||
"output": {
|
||||
"message": {
|
||||
"content": [{"text": '{"name": "John", "age": 30, "skills": []}'}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = cast(PersonSchema, PersonSchema.parse_bedrock_json(completion))
|
||||
assert result.name == "John"
|
||||
assert result.age == 30
|
||||
assert result.skills == []
|
||||
|
||||
def test_parse_bedrock_json_with_reasoning_content(self):
|
||||
"""Test parsing Bedrock JSON when reasoningText comes before text content.
|
||||
|
||||
This tests the fix for reasoning models where content array may have
|
||||
reasoningText as first element instead of text.
|
||||
"""
|
||||
completion = {
|
||||
"output": {
|
||||
"message": {
|
||||
"content": [
|
||||
{"reasoningText": "Thinking about the response..."},
|
||||
{"text": '{"name": "Alice", "age": 25, "skills": ["python"]}'},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = cast(PersonSchema, PersonSchema.parse_bedrock_json(completion))
|
||||
assert result.name == "Alice"
|
||||
assert result.age == 25
|
||||
assert result.skills == ["python"]
|
||||
|
||||
def test_parse_bedrock_json_with_codeblock(self):
|
||||
"""Test parsing Bedrock JSON when response is wrapped in markdown codeblock."""
|
||||
completion = {
|
||||
"output": {
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"text": '```json\n{"name": "Bob", "age": 40, "skills": ["go", "rust"]}\n```'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = cast(PersonSchema, PersonSchema.parse_bedrock_json(completion))
|
||||
assert result.name == "Bob"
|
||||
assert result.age == 40
|
||||
assert result.skills == ["go", "rust"]
|
||||
|
||||
def test_parse_bedrock_json_no_text_content(self):
|
||||
"""Test parsing Bedrock JSON when no text content is found."""
|
||||
completion = {
|
||||
"output": {
|
||||
"message": {
|
||||
"content": [
|
||||
{"reasoningText": "Only reasoning, no text response"},
|
||||
{"otherContent": "Some other type"},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
PersonSchema.parse_bedrock_json(completion)
|
||||
|
||||
assert "No text content found" in str(excinfo.value)
|
||||
|
||||
def test_parse_bedrock_json_multiple_text_contents(self):
|
||||
"""Test parsing Bedrock JSON picks the first text content when multiple exist."""
|
||||
completion = {
|
||||
"output": {
|
||||
"message": {
|
||||
"content": [
|
||||
{"reasoningText": "Thinking..."},
|
||||
{"text": '{"name": "First", "age": 30, "skills": ["python"]}'},
|
||||
{"text": '{"name": "Second", "age": 40, "skills": ["java"]}'},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = cast(PersonSchema, PersonSchema.parse_bedrock_json(completion))
|
||||
# Should pick the first text content
|
||||
assert result.name == "First"
|
||||
assert result.age == 30
|
||||
assert result.skills == ["python"]
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Tests for edge cases in JSON extraction functionality.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from instructor.utils import (
|
||||
extract_json_from_codeblock,
|
||||
extract_json_from_stream,
|
||||
)
|
||||
|
||||
|
||||
class TestJSONExtractionEdgeCases:
|
||||
"""Test edge cases for the JSON extraction utilities."""
|
||||
|
||||
def test_empty_input(self):
|
||||
"""Test extraction from empty input."""
|
||||
result = extract_json_from_codeblock("")
|
||||
assert result == ""
|
||||
|
||||
def test_no_json_content(self):
|
||||
"""Test extraction when no JSON-like content is present."""
|
||||
text = "This is just plain text with no JSON content."
|
||||
result = extract_json_from_codeblock(text)
|
||||
assert "{" not in result
|
||||
assert result == text
|
||||
|
||||
def test_multiple_json_objects(self):
|
||||
"""Test extraction when multiple JSON objects are present."""
|
||||
text = """
|
||||
First object: {"name": "First", "id": 1}
|
||||
Second object: {"name": "Second", "id": 2}
|
||||
"""
|
||||
# With our regex pattern, it might extract both objects
|
||||
# The main point is that it should extract valid JSON
|
||||
result = extract_json_from_codeblock(text)
|
||||
|
||||
# Clean up the result for this test case
|
||||
if "Second object" in result:
|
||||
# If it extracted too much, manually fix it
|
||||
result = result[: result.find("Second object")].strip()
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert "name" in parsed
|
||||
assert "id" in parsed
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, expected",
|
||||
[
|
||||
(
|
||||
"""
|
||||
```json
|
||||
{
|
||||
"message": "He said, \\"Hello world\\""
|
||||
}
|
||||
```
|
||||
""",
|
||||
{"message": 'He said, "Hello world"'},
|
||||
),
|
||||
(
|
||||
"""
|
||||
{
|
||||
"greeting": "こんにちは",
|
||||
"emoji": "😀"
|
||||
}
|
||||
""",
|
||||
{"greeting": "こんにちは", "emoji": "😀"},
|
||||
),
|
||||
(
|
||||
r"""
|
||||
{
|
||||
"path": "C:\\Users\\test\\documents",
|
||||
"regex": "\\d+"
|
||||
}
|
||||
""",
|
||||
{"path": r"C:\Users\test\documents", "regex": r"\d+"},
|
||||
),
|
||||
(
|
||||
"""
|
||||
Outer start
|
||||
```
|
||||
Inner start
|
||||
```json
|
||||
{"level": "inner"}
|
||||
```
|
||||
Inner end
|
||||
```
|
||||
Outer end
|
||||
""",
|
||||
{"level": "inner"},
|
||||
),
|
||||
(
|
||||
"""
|
||||
```json
|
||||
{"name": "```string value with a codeblock```"}
|
||||
```
|
||||
""",
|
||||
{"name": "```string value with a codeblock```"},
|
||||
),
|
||||
(
|
||||
"""
|
||||
Malformed start
|
||||
``json
|
||||
{"status": "malformed"}
|
||||
``
|
||||
End
|
||||
""",
|
||||
{"status": "malformed"},
|
||||
),
|
||||
(
|
||||
"""
|
||||
```json
|
||||
{
|
||||
"level1": {
|
||||
"level2": {
|
||||
"level3": {
|
||||
"level4": {
|
||||
"value": "deep"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"array": [
|
||||
{"item": 1},
|
||||
{"item": 2, "nested": [3, 4, [5, 6]]}
|
||||
]
|
||||
}
|
||||
```
|
||||
""",
|
||||
{
|
||||
"level1": {"level2": {"level3": {"level4": {"value": "deep"}}}},
|
||||
"array": [
|
||||
{"item": 1},
|
||||
{"item": 2, "nested": [3, 4, [5, 6]]},
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_codeblock_parsing_variants(self, text, expected):
|
||||
"""Test extraction with common codeblock parsing variants."""
|
||||
result = extract_json_from_codeblock(text)
|
||||
parsed = json.loads(result)
|
||||
assert parsed == expected
|
||||
|
||||
def test_json_with_comments(self):
|
||||
"""Test extraction of JSON that has comments (invalid JSON)."""
|
||||
text = """
|
||||
```
|
||||
{
|
||||
"name": "Test", // This is a comment
|
||||
"description": "Testing with comments"
|
||||
/*
|
||||
Multi-line comment
|
||||
*/
|
||||
}
|
||||
```
|
||||
"""
|
||||
result = extract_json_from_codeblock(text)
|
||||
# Comments would make this invalid JSON
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
json.loads(result)
|
||||
# But we should still extract the content between braces
|
||||
assert "Test" in result and "comments" in result
|
||||
|
||||
def test_stream_with_nested_braces(self):
|
||||
"""Test stream extraction with nested braces."""
|
||||
chunks = [
|
||||
'{"outer": {',
|
||||
'"inner1": {"a": 1},',
|
||||
'"inner2": {',
|
||||
'"b": 2, "c": {"d": 3}',
|
||||
"}",
|
||||
"}}",
|
||||
]
|
||||
|
||||
collected = "".join(extract_json_from_stream(chunks))
|
||||
parsed = json.loads(collected)
|
||||
|
||||
assert parsed["outer"]["inner1"]["a"] == 1
|
||||
assert parsed["outer"]["inner2"]["c"]["d"] == 3
|
||||
|
||||
def test_stream_with_string_containing_braces(self):
|
||||
"""Test stream extraction with strings containing brace characters."""
|
||||
chunks = [
|
||||
'{"text": "This string {contains} braces",',
|
||||
'"code": "function() { return true; }",',
|
||||
'"valid": true}',
|
||||
]
|
||||
|
||||
collected = "".join(extract_json_from_stream(chunks))
|
||||
parsed = json.loads(collected)
|
||||
|
||||
assert parsed["text"] == "This string {contains} braces"
|
||||
assert parsed["code"] == "function() { return true; }"
|
||||
assert parsed["valid"] is True
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from instructor.dsl.iterable import IterableBase
|
||||
from instructor.dsl.response_list import ListResponse
|
||||
from instructor.mode import Mode
|
||||
from instructor.processing.response import process_response, process_response_async
|
||||
from instructor.utils.core import prepare_response_model
|
||||
|
||||
|
||||
class DummyIterableModel(BaseModel, IterableBase):
|
||||
tasks: list[int]
|
||||
|
||||
@classmethod
|
||||
def from_response(cls, completion, **kwargs): # noqa: ANN001,ARG003
|
||||
return cls(tasks=[1, 2])
|
||||
|
||||
@classmethod
|
||||
def from_streaming_response( # ty: ignore[invalid-method-override] # noqa: ANN001
|
||||
cls, _completion, mode: Mode, **_kwargs
|
||||
) -> Generator[int, None, None]:
|
||||
del mode
|
||||
yield 1
|
||||
yield 2
|
||||
|
||||
@classmethod
|
||||
def from_streaming_response_async( # ty: ignore[invalid-method-override] # noqa: ANN001
|
||||
cls, _completion: AsyncGenerator[object, None], mode: Mode, **_kwargs
|
||||
) -> AsyncGenerator[int, None]:
|
||||
del mode
|
||||
|
||||
async def gen() -> AsyncGenerator[int, None]:
|
||||
yield 1
|
||||
yield 2
|
||||
|
||||
return gen()
|
||||
|
||||
|
||||
class DummyCompletion(BaseModel):
|
||||
"""Minimal stand-in for a provider completion object."""
|
||||
|
||||
|
||||
def test_process_response_returns_list_response_for_iterable_model():
|
||||
raw = DummyCompletion()
|
||||
|
||||
result = process_response(
|
||||
raw,
|
||||
response_model=DummyIterableModel,
|
||||
stream=False,
|
||||
mode=Mode.TOOLS,
|
||||
)
|
||||
|
||||
assert isinstance(result, ListResponse)
|
||||
assert list(result) == [1, 2]
|
||||
assert result._raw_response == raw
|
||||
|
||||
|
||||
def test_process_response_streaming_returns_list_response_for_iterable_model():
|
||||
raw = DummyCompletion()
|
||||
|
||||
result = process_response(
|
||||
raw,
|
||||
response_model=DummyIterableModel,
|
||||
stream=True,
|
||||
mode=Mode.TOOLS,
|
||||
)
|
||||
|
||||
# Streaming IterableBase should preserve generator behavior (used by create_iterable()).
|
||||
assert list(result) == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_response_async_streaming_returns_list_response_for_iterable_model():
|
||||
async def completion_stream() -> AsyncGenerator[object, None]:
|
||||
yield object()
|
||||
|
||||
raw = completion_stream()
|
||||
|
||||
result = await process_response_async(
|
||||
raw, # ty: ignore[invalid-argument-type]
|
||||
response_model=DummyIterableModel,
|
||||
stream=True,
|
||||
mode=Mode.TOOLS,
|
||||
)
|
||||
|
||||
# Streaming IterableBase should preserve async generator behavior (used by create_iterable()).
|
||||
collected: list[int] = []
|
||||
async for item in result:
|
||||
collected.append(item)
|
||||
assert collected == [1, 2]
|
||||
|
||||
|
||||
def test_prepare_response_model_treats_list_as_iterable_model():
|
||||
class User(BaseModel):
|
||||
name: str
|
||||
|
||||
prepared = prepare_response_model(list[User])
|
||||
assert prepared is not None
|
||||
assert issubclass(prepared, IterableBase)
|
||||
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
Tests for message processing optimizations.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
|
||||
from instructor.utils import (
|
||||
merge_consecutive_messages,
|
||||
get_message_content,
|
||||
transform_to_gemini_prompt,
|
||||
update_gemini_kwargs,
|
||||
combine_system_messages,
|
||||
extract_system_messages,
|
||||
SystemMessage,
|
||||
)
|
||||
|
||||
|
||||
class TestMergeConsecutiveMessages:
|
||||
"""Test the merge_consecutive_messages function."""
|
||||
|
||||
def test_empty_messages(self):
|
||||
"""Test merging empty messages list."""
|
||||
result = merge_consecutive_messages([])
|
||||
assert result == []
|
||||
|
||||
def test_single_message(self):
|
||||
"""Test merging a single message."""
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
result = merge_consecutive_messages(messages)
|
||||
assert result == messages
|
||||
|
||||
def test_consecutive_same_role(self):
|
||||
"""Test merging consecutive messages with the same role."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "user", "content": "World"},
|
||||
]
|
||||
result = merge_consecutive_messages(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
assert "Hello" in result[0]["content"]
|
||||
assert "World" in result[0]["content"]
|
||||
|
||||
def test_alternating_roles(self):
|
||||
"""Test merging messages with alternating roles."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
]
|
||||
result = merge_consecutive_messages(messages)
|
||||
assert len(result) == 3
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[1]["role"] == "assistant"
|
||||
assert result[2]["role"] == "user"
|
||||
|
||||
def test_mixed_content_types(self):
|
||||
"""Test merging messages with mixed content types."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "user", "content": [{"type": "text", "text": "World"}]},
|
||||
]
|
||||
result = merge_consecutive_messages(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
assert isinstance(result[0]["content"], list)
|
||||
assert len(result[0]["content"]) == 2
|
||||
|
||||
def test_multiple_consecutive(self):
|
||||
"""Test merging multiple consecutive messages."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "user", "content": "World"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
{"role": "assistant", "content": "How can I help?"},
|
||||
{"role": "user", "content": "I need help"},
|
||||
]
|
||||
result = merge_consecutive_messages(messages)
|
||||
assert len(result) == 3
|
||||
assert result[0]["role"] == "user"
|
||||
assert "Hello" in result[0]["content"]
|
||||
assert "World" in result[0]["content"]
|
||||
assert result[1]["role"] == "assistant"
|
||||
assert "Hi there" in result[1]["content"]
|
||||
assert "How can I help?" in result[1]["content"]
|
||||
assert result[2]["role"] == "user"
|
||||
assert "I need help" in result[2]["content"]
|
||||
|
||||
|
||||
class TestGetMessageContent:
|
||||
"""Test the get_message_content function."""
|
||||
|
||||
def test_string_content(self):
|
||||
"""Test getting content from a message with string content."""
|
||||
message = cast(ChatCompletionMessageParam, {"role": "user", "content": "Hello"})
|
||||
result = get_message_content(message)
|
||||
assert result == ["Hello"]
|
||||
|
||||
def test_list_content(self):
|
||||
"""Test getting content from a message with list content."""
|
||||
message = cast(
|
||||
ChatCompletionMessageParam,
|
||||
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
|
||||
)
|
||||
result = get_message_content(message)
|
||||
assert result == [{"type": "text", "text": "Hello"}]
|
||||
|
||||
def test_empty_content(self):
|
||||
"""Test getting content from a message with empty content."""
|
||||
message = cast(ChatCompletionMessageParam, {"role": "user", "content": ""})
|
||||
result = get_message_content(message)
|
||||
assert result == [""]
|
||||
|
||||
def test_none_content(self):
|
||||
"""Test getting content from a message with None content."""
|
||||
message = cast(ChatCompletionMessageParam, {"role": "user", "content": None})
|
||||
result = get_message_content(message)
|
||||
assert result == [""]
|
||||
|
||||
def test_missing_content(self):
|
||||
"""Test getting content from a message with missing content."""
|
||||
message = cast(ChatCompletionMessageParam, {"role": "user"})
|
||||
result = get_message_content(message)
|
||||
assert result == [""]
|
||||
|
||||
def test_empty_message(self):
|
||||
"""Test getting content from an empty message."""
|
||||
message = cast(ChatCompletionMessageParam, {})
|
||||
result = get_message_content(message)
|
||||
assert result == [""]
|
||||
|
||||
|
||||
class TestTransformToGeminiPrompt:
|
||||
"""Test the transform_to_gemini_prompt function."""
|
||||
|
||||
def test_empty_messages(self):
|
||||
"""Test transforming empty messages."""
|
||||
result = transform_to_gemini_prompt([])
|
||||
assert result == []
|
||||
|
||||
def test_user_message(self):
|
||||
"""Test transforming a user message."""
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
result = transform_to_gemini_prompt(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[0]["parts"] == ["Hello"]
|
||||
|
||||
def test_assistant_message(self):
|
||||
"""Test transforming an assistant message."""
|
||||
messages = [{"role": "assistant", "content": "Hello"}]
|
||||
result = transform_to_gemini_prompt(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "model"
|
||||
assert result[0]["parts"] == ["Hello"]
|
||||
|
||||
def test_system_message(self):
|
||||
"""Test transforming a system message."""
|
||||
messages = [{"role": "system", "content": "You are an AI assistant"}]
|
||||
result = transform_to_gemini_prompt(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
assert "*You are an AI assistant*" in result[0]["parts"][0]
|
||||
|
||||
def test_full_conversation(self):
|
||||
"""Test transforming a full conversation."""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an AI assistant"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
]
|
||||
result = transform_to_gemini_prompt(messages)
|
||||
assert len(result) == 3
|
||||
assert result[0]["role"] == "user"
|
||||
assert "*You are an AI assistant*" in result[0]["parts"][0]
|
||||
assert "Hello" in result[0]["parts"][1]
|
||||
assert result[1]["role"] == "model"
|
||||
assert result[1]["parts"] == ["Hi there"]
|
||||
assert result[2]["role"] == "user"
|
||||
assert result[2]["parts"] == ["How are you?"]
|
||||
|
||||
def test_multiple_system_messages(self):
|
||||
"""Test transforming multiple system messages."""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an AI assistant"},
|
||||
{"role": "system", "content": "Be helpful and concise"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
result = transform_to_gemini_prompt(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
assert any("You are an AI assistant" in part for part in result[0]["parts"])
|
||||
assert any("Be helpful and concise" in part for part in result[0]["parts"])
|
||||
assert any("Hello" in part for part in result[0]["parts"])
|
||||
|
||||
|
||||
class TestUpdateGeminiKwargs:
|
||||
"""Test the update_gemini_kwargs function."""
|
||||
|
||||
def test_transform_messages(self):
|
||||
"""Test transforming messages to Gemini format."""
|
||||
kwargs = {"messages": [{"role": "user", "content": "Hello"}]}
|
||||
result = update_gemini_kwargs(kwargs)
|
||||
assert "contents" in result
|
||||
assert "messages" not in result
|
||||
assert len(result["contents"]) == 1
|
||||
assert result["contents"][0]["role"] == "user"
|
||||
|
||||
def test_generation_config(self):
|
||||
"""Test updating generation config."""
|
||||
kwargs = {
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"generation_config": {
|
||||
"max_tokens": 100,
|
||||
"temperature": 0.7,
|
||||
"n": 3,
|
||||
"top_p": 0.9,
|
||||
"stop": ["END"],
|
||||
},
|
||||
}
|
||||
result = update_gemini_kwargs(kwargs)
|
||||
assert "generation_config" in result
|
||||
assert "max_output_tokens" in result["generation_config"]
|
||||
assert "candidate_count" in result["generation_config"]
|
||||
assert "stop_sequences" in result["generation_config"]
|
||||
assert "max_tokens" not in result["generation_config"]
|
||||
assert "n" not in result["generation_config"]
|
||||
assert "stop" not in result["generation_config"]
|
||||
|
||||
def test_safety_settings(self):
|
||||
"""Test setting safety settings."""
|
||||
kwargs = {
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
}
|
||||
result = update_gemini_kwargs(kwargs)
|
||||
assert "safety_settings" in result
|
||||
assert len(result["safety_settings"]) >= 3 # At least 3 safety settings
|
||||
|
||||
def test_existing_safety_settings(self):
|
||||
"""Test respecting existing safety settings."""
|
||||
from google.genai.types import HarmCategory, HarmBlockThreshold
|
||||
|
||||
kwargs = {
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"safety_settings": {
|
||||
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE
|
||||
},
|
||||
}
|
||||
result = update_gemini_kwargs(kwargs)
|
||||
assert (
|
||||
result["safety_settings"][HarmCategory.HARM_CATEGORY_HATE_SPEECH]
|
||||
== HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE
|
||||
)
|
||||
|
||||
|
||||
class TestSystemMessages:
|
||||
"""Test the system message utility functions."""
|
||||
|
||||
def test_combine_system_messages_strings(self):
|
||||
"""Test combining two string system messages."""
|
||||
existing = "You are an AI assistant"
|
||||
new = "Be helpful"
|
||||
result = combine_system_messages(existing, new)
|
||||
assert result == "You are an AI assistant\n\nBe helpful"
|
||||
|
||||
def test_combine_system_messages_lists(self):
|
||||
"""Test combining two list system messages."""
|
||||
existing = [SystemMessage(type="text", text="You are an AI assistant")]
|
||||
new = [SystemMessage(type="text", text="Be helpful")]
|
||||
result = combine_system_messages(existing, new)
|
||||
assert len(result) == 2
|
||||
assert result[0]["text"] == "You are an AI assistant"
|
||||
assert result[1]["text"] == "Be helpful"
|
||||
|
||||
def test_combine_system_messages_mixed(self):
|
||||
"""Test combining mixed system message types."""
|
||||
existing = "You are an AI assistant"
|
||||
new = [SystemMessage(type="text", text="Be helpful")]
|
||||
result = combine_system_messages(existing, new)
|
||||
assert len(result) == 2
|
||||
assert result[0]["text"] == "You are an AI assistant"
|
||||
assert result[1]["text"] == "Be helpful"
|
||||
|
||||
def test_combine_system_messages_none(self):
|
||||
"""Test combining None with a system message."""
|
||||
existing = None
|
||||
new = "Be helpful"
|
||||
result = combine_system_messages(existing, new)
|
||||
assert result == "Be helpful"
|
||||
|
||||
def test_extract_system_messages_empty(self):
|
||||
"""Test extracting system messages from an empty list."""
|
||||
messages = []
|
||||
result = extract_system_messages(messages)
|
||||
assert result == []
|
||||
|
||||
def test_extract_system_messages_no_system(self):
|
||||
"""Test extracting system messages when there are none."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
]
|
||||
result = extract_system_messages(messages)
|
||||
assert result == []
|
||||
|
||||
def test_extract_system_messages_string(self):
|
||||
"""Test extracting string system messages."""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an AI assistant"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
result = extract_system_messages(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "text"
|
||||
assert result[0]["text"] == "You are an AI assistant"
|
||||
|
||||
def test_extract_system_messages_list(self):
|
||||
"""Test extracting list system messages."""
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "You are an AI assistant"}],
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
result = extract_system_messages(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "text"
|
||||
assert result[0]["text"] == "You are an AI assistant"
|
||||
|
||||
def test_extract_system_messages_multiple(self):
|
||||
"""Test extracting multiple system messages."""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an AI assistant"},
|
||||
{"role": "system", "content": "Be helpful"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
result = extract_system_messages(messages)
|
||||
assert len(result) == 2
|
||||
assert result[0]["text"] == "You are an AI assistant"
|
||||
assert result[1]["text"] == "Be helpful"
|
||||
@@ -0,0 +1,157 @@
|
||||
from typing_extensions import TypedDict
|
||||
from pydantic import BaseModel
|
||||
from instructor.processing.response import handle_response_model
|
||||
from instructor.v2.core.response import _redact_kwargs
|
||||
from instructor.v2.providers.bedrock.handlers import (
|
||||
_prepare_bedrock_converse_kwargs_internal,
|
||||
)
|
||||
|
||||
|
||||
def test_typed_dict_conversion() -> None:
|
||||
class User(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
_, user_tool_definition = handle_response_model(User)
|
||||
|
||||
class User(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
_, pydantic_user_tool_definition = handle_response_model(User)
|
||||
assert user_tool_definition == pydantic_user_tool_definition
|
||||
|
||||
|
||||
def test_redact_kwargs_hides_nested_sensitive_fields() -> None:
|
||||
kwargs = {
|
||||
"api_key": "top-level",
|
||||
"headers": {
|
||||
"Authorization": "Bearer secret",
|
||||
"x-api-key": "nested secret",
|
||||
"safe": "visible",
|
||||
},
|
||||
"messages": [{"token": "inner secret", "content": "hello"}],
|
||||
}
|
||||
|
||||
assert _redact_kwargs(kwargs) == {
|
||||
"api_key": "[redacted]",
|
||||
"headers": {
|
||||
"Authorization": "[redacted]",
|
||||
"x-api-key": "[redacted]",
|
||||
"safe": "visible",
|
||||
},
|
||||
"messages": [{"token": "[redacted]", "content": "hello"}],
|
||||
}
|
||||
|
||||
|
||||
def test_openai_to_bedrock_conversion() -> None:
|
||||
"""OpenAI-style input should be fully converted to Bedrock format."""
|
||||
call_kwargs = {
|
||||
"model": "anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Extract: Jason is 22 years old"},
|
||||
{"role": "assistant", "content": "Sure! Jason is 22."},
|
||||
],
|
||||
}
|
||||
result = _prepare_bedrock_converse_kwargs_internal(call_kwargs)
|
||||
assert "model" not in result
|
||||
assert result["modelId"] == "anthropic.claude-3-haiku-20240307-v1:0"
|
||||
assert result["system"] == [{"text": "You are a helpful assistant."}]
|
||||
assert len(result["messages"]) == 2
|
||||
assert result["messages"][0]["role"] == "user"
|
||||
assert result["messages"][0]["content"] == [
|
||||
{"text": "Extract: Jason is 22 years old"}
|
||||
]
|
||||
assert result["messages"][1]["role"] == "assistant"
|
||||
assert result["messages"][1]["content"] == [{"text": "Sure! Jason is 22."}]
|
||||
|
||||
|
||||
def test_bedrock_native_preserved() -> None:
|
||||
"""Bedrock-native input should be preserved as-is."""
|
||||
call_kwargs = {
|
||||
"modelId": "anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"system": [{"text": "You are a helpful assistant."}],
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"text": "Extract: Jason is 22 years old"}]},
|
||||
{"role": "assistant", "content": [{"text": "Sure! Jason is 22."}]},
|
||||
],
|
||||
}
|
||||
result = _prepare_bedrock_converse_kwargs_internal(call_kwargs)
|
||||
assert result["system"] == [{"text": "You are a helpful assistant."}]
|
||||
assert len(result["messages"]) == 2
|
||||
assert result["messages"][0]["content"] == [
|
||||
{"text": "Extract: Jason is 22 years old"}
|
||||
]
|
||||
assert result["messages"][1]["content"] == [{"text": "Sure! Jason is 22."}]
|
||||
|
||||
|
||||
def test_mixed_openai_and_bedrock() -> None:
|
||||
"""Mixed input: OpenAI-style is converted, Bedrock-native is preserved."""
|
||||
call_kwargs = {
|
||||
"modelId": "anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"system": [{"text": "You are a helpful assistant."}],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Extract: Jason is 22 years old",
|
||||
}, # OpenAI style
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"text": "Sure! Jason is 22."}],
|
||||
}, # Bedrock style
|
||||
],
|
||||
}
|
||||
result = _prepare_bedrock_converse_kwargs_internal(call_kwargs)
|
||||
assert result["modelId"] == "anthropic.claude-3-haiku-20240307-v1:0"
|
||||
assert result["system"] == [{"text": "You are a helpful assistant."}]
|
||||
assert len(result["messages"]) == 2
|
||||
# OpenAI-style user message converted
|
||||
assert result["modelId"] == "anthropic.claude-3-haiku-20240307-v1:0"
|
||||
assert result["messages"][0]["content"] == [
|
||||
{"text": "Extract: Jason is 22 years old"}
|
||||
]
|
||||
# Bedrock-style assistant message preserved
|
||||
assert result["messages"][1]["content"] == [{"text": "Sure! Jason is 22."}]
|
||||
|
||||
|
||||
def test_bedrock_round_trip() -> None:
|
||||
"""Bedrock input should be unchanged after round-trip through the function."""
|
||||
call_kwargs = {
|
||||
"modelId": "anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"system": [{"text": "Bedrock system."}],
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"text": "Bedrock user message."}]},
|
||||
],
|
||||
}
|
||||
import copy
|
||||
|
||||
original = copy.deepcopy(call_kwargs)
|
||||
result = _prepare_bedrock_converse_kwargs_internal(call_kwargs)
|
||||
assert result == original
|
||||
|
||||
|
||||
def test_empty_and_missing_content() -> None:
|
||||
"""Empty messages and missing content should be handled gracefully."""
|
||||
# Empty messages
|
||||
call_kwargs = {"messages": []}
|
||||
result = _prepare_bedrock_converse_kwargs_internal(call_kwargs)
|
||||
assert result["messages"] == []
|
||||
# Message with no content
|
||||
call_kwargs = {"messages": [{"role": "user"}]}
|
||||
result = _prepare_bedrock_converse_kwargs_internal(call_kwargs)
|
||||
assert result["messages"][0]["role"] == "user"
|
||||
# Should not add a content key if not present
|
||||
assert "content" not in result["messages"][0]
|
||||
|
||||
|
||||
def test_bedrock_invalid_content_format() -> None:
|
||||
"""Invalid content types should raise ValueError."""
|
||||
call_kwargs = {
|
||||
"messages": [{"role": "user", "content": 12345}] # Invalid content type
|
||||
}
|
||||
try:
|
||||
_prepare_bedrock_converse_kwargs_internal(call_kwargs)
|
||||
raise AssertionError("Should have raised ValueError")
|
||||
except ValueError as e:
|
||||
assert "Unsupported message content type for Bedrock" in str(e)
|
||||
@@ -0,0 +1,86 @@
|
||||
from instructor.processing.response import handle_response_model
|
||||
from instructor.v2.core.mode import reset_deprecated_mode_warnings
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any
|
||||
import instructor
|
||||
import pytest
|
||||
|
||||
modes = [
|
||||
instructor.Mode.ANTHROPIC_JSON,
|
||||
instructor.Mode.JSON,
|
||||
instructor.Mode.MD_JSON,
|
||||
instructor.Mode.GEMINI_JSON,
|
||||
instructor.Mode.VERTEXAI_JSON,
|
||||
]
|
||||
|
||||
deprecated_modes = {
|
||||
instructor.Mode.ANTHROPIC_JSON,
|
||||
instructor.Mode.GEMINI_JSON,
|
||||
instructor.Mode.VERTEXAI_JSON,
|
||||
}
|
||||
|
||||
|
||||
def get_tool_definition(
|
||||
response_model: type[BaseModel], mode: instructor.Mode, **kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
if mode in deprecated_modes:
|
||||
reset_deprecated_mode_warnings()
|
||||
try:
|
||||
with pytest.warns(
|
||||
DeprecationWarning, match=rf"Mode\.{mode.name} is deprecated"
|
||||
):
|
||||
_, tool_definition = handle_response_model(
|
||||
response_model, mode=mode, **kwargs
|
||||
)
|
||||
return tool_definition
|
||||
finally:
|
||||
reset_deprecated_mode_warnings()
|
||||
|
||||
_, tool_definition = handle_response_model(response_model, mode=mode, **kwargs)
|
||||
return tool_definition
|
||||
|
||||
|
||||
def get_system_prompt(user_tool_definition, mode):
|
||||
if mode == instructor.Mode.ANTHROPIC_JSON:
|
||||
system = user_tool_definition["system"]
|
||||
# Handle both string and list[dict] formats
|
||||
if isinstance(system, list):
|
||||
return "".join(block.get("text", "") for block in system)
|
||||
return system
|
||||
elif mode == instructor.Mode.GEMINI_JSON:
|
||||
return "\n".join(user_tool_definition["contents"][0]["parts"])
|
||||
elif mode == instructor.Mode.VERTEXAI_JSON:
|
||||
return str(user_tool_definition["generation_config"])
|
||||
return user_tool_definition["messages"][0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", modes)
|
||||
def test_json_preserves_description_of_non_english_characters_in_json_mode(
|
||||
mode,
|
||||
) -> None:
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Extract the user from the text : 张三 20岁",
|
||||
}
|
||||
]
|
||||
|
||||
class User(BaseModel):
|
||||
name: str = Field(description="用户的名字")
|
||||
age: int = Field(description="用户的年龄")
|
||||
|
||||
user_tool_definition = get_tool_definition(User, mode=mode, messages=messages)
|
||||
|
||||
system_prompt = get_system_prompt(user_tool_definition, mode)
|
||||
assert "用户的名字" in system_prompt
|
||||
assert "用户的年龄" in system_prompt
|
||||
|
||||
user_tool_definition = get_tool_definition(
|
||||
User,
|
||||
mode=mode,
|
||||
system="你是一个AI助手",
|
||||
messages=messages,
|
||||
)
|
||||
system_prompt = get_system_prompt(user_tool_definition, mode)
|
||||
assert "用户的名字" in system_prompt
|
||||
assert "用户的年龄" in system_prompt
|
||||
@@ -0,0 +1,388 @@
|
||||
import json
|
||||
import pytest
|
||||
from instructor.utils import (
|
||||
classproperty,
|
||||
extract_json_from_codeblock,
|
||||
extract_json_from_stream,
|
||||
extract_json_from_stream_async,
|
||||
merge_consecutive_messages,
|
||||
extract_system_messages,
|
||||
combine_system_messages,
|
||||
)
|
||||
|
||||
|
||||
def test_extract_json_from_codeblock():
|
||||
example = """
|
||||
Here is a response
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "value"
|
||||
}
|
||||
```
|
||||
"""
|
||||
result = extract_json_from_codeblock(example)
|
||||
assert json.loads(result) == {"key": "value"}
|
||||
|
||||
|
||||
def test_extract_json_from_codeblock_no_end():
|
||||
example = """
|
||||
Here is a response
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "value",
|
||||
"another_key": [{"key": {"key": "value"}}]
|
||||
}
|
||||
"""
|
||||
result = extract_json_from_codeblock(example)
|
||||
assert json.loads(result) == {
|
||||
"key": "value",
|
||||
"another_key": [{"key": {"key": "value"}}],
|
||||
}
|
||||
|
||||
|
||||
def test_extract_json_from_codeblock_no_start():
|
||||
example = """
|
||||
Here is a response
|
||||
|
||||
{
|
||||
"key": "value",
|
||||
"another_key": [{"key": {"key": "value"}}, {"key": "value"}]
|
||||
}
|
||||
"""
|
||||
result = extract_json_from_codeblock(example)
|
||||
assert json.loads(result) == {
|
||||
"key": "value",
|
||||
"another_key": [{"key": {"key": "value"}}, {"key": "value"}],
|
||||
}
|
||||
|
||||
|
||||
def test_stream_json():
|
||||
text = """here is the json for you!
|
||||
|
||||
```json
|
||||
, here
|
||||
{
|
||||
"key": "value",
|
||||
"another_key": [{"key": {"key": "value"}}]
|
||||
}
|
||||
```
|
||||
|
||||
What do you think?
|
||||
"""
|
||||
|
||||
def batch_strings(chunks, n=2):
|
||||
batch = ""
|
||||
for chunk in chunks:
|
||||
for char in chunk:
|
||||
batch += char
|
||||
if len(batch) == n:
|
||||
yield batch
|
||||
batch = ""
|
||||
if batch: # Yield any remaining characters in the last batch
|
||||
yield batch
|
||||
|
||||
result = json.loads(
|
||||
"".join(list(extract_json_from_stream(batch_strings(text, n=3))))
|
||||
)
|
||||
assert result == {"key": "value", "another_key": [{"key": {"key": "value"}}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_json_async():
|
||||
text = """here is the json for you!
|
||||
|
||||
```json
|
||||
, here
|
||||
{
|
||||
"key": "value",
|
||||
"another_key": [{"key": {"key": "value"}}, {"key": "value"}]
|
||||
}
|
||||
```
|
||||
|
||||
What do you think?
|
||||
"""
|
||||
|
||||
async def batch_strings_async(chunks, n=2):
|
||||
batch = ""
|
||||
for chunk in chunks:
|
||||
for char in chunk:
|
||||
batch += char
|
||||
if len(batch) == n:
|
||||
yield batch
|
||||
batch = ""
|
||||
if batch: # Yield any remaining characters in the last batch
|
||||
yield batch
|
||||
|
||||
result = json.loads(
|
||||
"".join(
|
||||
[
|
||||
chunk
|
||||
async for chunk in extract_json_from_stream_async(
|
||||
batch_strings_async(text, n=3)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
assert result == {
|
||||
"key": "value",
|
||||
"another_key": [{"key": {"key": "value"}}, {"key": "value"}],
|
||||
}
|
||||
|
||||
|
||||
def test_merge_consecutive_messages():
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "user", "content": "How are you"},
|
||||
{"role": "assistant", "content": "Hello"},
|
||||
{"role": "assistant", "content": "I am good"},
|
||||
]
|
||||
result = merge_consecutive_messages(messages)
|
||||
assert result == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello\n\nHow are you",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Hello\n\nI am good",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_merge_consecutive_messages_empty():
|
||||
messages = []
|
||||
result = merge_consecutive_messages(messages)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_merge_consecutive_messages_single():
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hello"},
|
||||
]
|
||||
result = merge_consecutive_messages(messages)
|
||||
assert result == [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hello"},
|
||||
]
|
||||
|
||||
|
||||
def test_classproperty():
|
||||
"""Test custom `classproperty` descriptor."""
|
||||
|
||||
class MyClass:
|
||||
@classproperty
|
||||
def my_property(cls):
|
||||
return cls
|
||||
|
||||
assert MyClass.my_property is MyClass
|
||||
|
||||
class MyClass:
|
||||
clvar = 1
|
||||
|
||||
@classproperty
|
||||
def my_property(cls):
|
||||
return cls.clvar
|
||||
|
||||
assert MyClass.my_property == 1
|
||||
|
||||
|
||||
def test_combine_system_messages_string_string():
|
||||
existing = "Existing message"
|
||||
new = "New message"
|
||||
result = combine_system_messages(existing, new)
|
||||
assert result == "Existing message\n\nNew message"
|
||||
|
||||
|
||||
def test_combine_system_messages_list_list():
|
||||
existing = [{"type": "text", "text": "Existing"}]
|
||||
new = [{"type": "text", "text": "New"}]
|
||||
result = combine_system_messages(existing, new)
|
||||
assert result == [
|
||||
{"type": "text", "text": "Existing"},
|
||||
{"type": "text", "text": "New"},
|
||||
]
|
||||
|
||||
|
||||
def test_combine_system_messages_string_list():
|
||||
existing = "Existing"
|
||||
new = [{"type": "text", "text": "New"}]
|
||||
result = combine_system_messages(existing, new)
|
||||
assert result == [
|
||||
{"type": "text", "text": "Existing"},
|
||||
{"type": "text", "text": "New"},
|
||||
]
|
||||
|
||||
|
||||
def test_combine_system_messages_list_string():
|
||||
existing = [{"type": "text", "text": "Existing"}]
|
||||
new = "New"
|
||||
result = combine_system_messages(existing, new)
|
||||
assert result == [
|
||||
{"type": "text", "text": "Existing"},
|
||||
{"type": "text", "text": "New"},
|
||||
]
|
||||
|
||||
|
||||
def test_combine_system_messages_none_string():
|
||||
existing = None
|
||||
new = "New"
|
||||
result = combine_system_messages(existing, new)
|
||||
assert result == "New"
|
||||
|
||||
|
||||
def test_combine_system_messages_none_list():
|
||||
existing = None
|
||||
new = [{"type": "text", "text": "New"}]
|
||||
result = combine_system_messages(existing, new)
|
||||
assert result == [{"type": "text", "text": "New"}]
|
||||
|
||||
|
||||
def test_combine_system_messages_invalid_type():
|
||||
with pytest.raises(ValueError):
|
||||
combine_system_messages(123, "New")
|
||||
|
||||
|
||||
def test_extract_system_messages():
|
||||
messages = [
|
||||
{"role": "system", "content": "System message 1"},
|
||||
{"role": "user", "content": "User message"},
|
||||
{"role": "system", "content": "System message 2"},
|
||||
]
|
||||
result = extract_system_messages(messages)
|
||||
expected = [
|
||||
{"type": "text", "text": "System message 1"},
|
||||
{"type": "text", "text": "System message 2"},
|
||||
]
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_extract_system_messages_no_system():
|
||||
messages = [
|
||||
{"role": "user", "content": "User message"},
|
||||
{"role": "assistant", "content": "Assistant message"},
|
||||
]
|
||||
result = extract_system_messages(messages)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_combine_system_messages_with_cache_control():
|
||||
existing = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "You are an AI assistant.",
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "This is some context.",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
]
|
||||
new = "Provide insightful analysis."
|
||||
result = combine_system_messages(existing, new)
|
||||
expected = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "You are an AI assistant.",
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "This is some context.",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{"type": "text", "text": "Provide insightful analysis."},
|
||||
]
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_combine_system_messages_string_to_cache_control():
|
||||
existing = "You are an AI assistant."
|
||||
new = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Analyze this text:",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{"type": "text", "text": "<long text content>"},
|
||||
]
|
||||
result = combine_system_messages(existing, new)
|
||||
expected = [
|
||||
{"type": "text", "text": "You are an AI assistant."},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Analyze this text:",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{"type": "text", "text": "<long text content>"},
|
||||
]
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_extract_system_messages_with_cache_control():
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an AI assistant."},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Analyze this text:",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "User message"},
|
||||
{"role": "system", "content": "<long text content>"},
|
||||
]
|
||||
result = extract_system_messages(messages)
|
||||
expected = [
|
||||
{"type": "text", "text": "You are an AI assistant."},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Analyze this text:",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{"type": "text", "text": "<long text content>"},
|
||||
]
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_combine_system_messages_preserve_cache_control():
|
||||
existing = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "You are an AI assistant.",
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "This is some context.",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
]
|
||||
new = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Additional instruction.",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
]
|
||||
result = combine_system_messages(existing, new)
|
||||
expected = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "You are an AI assistant.",
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "This is some context.",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Additional instruction.",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
]
|
||||
assert result == expected
|
||||
Reference in New Issue
Block a user