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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:36:38 +08:00
commit 97e91a83f3
978 changed files with 159975 additions and 0 deletions
+66
View File
@@ -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"
+180
View File
@@ -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."
)
+168
View File
@@ -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"
+371
View File
@@ -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"
+393
View File
@@ -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)
+344
View File
@@ -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"
+157
View File
@@ -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
+388
View File
@@ -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