참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

View File

@@ -0,0 +1,40 @@
# conftest.py
import os
import pytest
import instructor
if not os.getenv("GOOGLE_API_KEY"):
pytest.skip(
"GOOGLE_API_KEY environment variable not set",
allow_module_level=True,
)
if not os.getenv("GOOGLE_GENAI_MODEL"):
pytest.skip(
"GOOGLE_GENAI_MODEL environment variable not set",
allow_module_level=True,
)
try:
from google.genai import Client
except ImportError: # pragma: no cover - optional dependency
pytest.skip("google-genai package is not installed", allow_module_level=True)
@pytest.fixture(scope="function")
def client():
yield Client()
@pytest.fixture(scope="function")
def aclient():
yield Client()
@pytest.fixture(scope="function")
def genai_client():
# Use the recommended model for sync client, let the test set the mode
return instructor.from_provider(
os.getenv("GOOGLE_GENAI_MODEL", "google/gemini-pro"),
)

View File

@@ -0,0 +1,129 @@
import pytest
from decimal import Decimal
from pydantic import BaseModel, field_validator
import instructor
from .util import models, modes
class Receipt(BaseModel):
item: str
quantity: int
price: Decimal
total: Decimal
@field_validator("price", "total", mode="before")
@classmethod
def parse_decimals(cls, v):
if isinstance(v, (str, float, int)):
return Decimal(str(v))
return v
class Invoice(BaseModel):
receipts: list[Receipt]
grand_total: Decimal
@field_validator("grand_total", mode="before")
@classmethod
def parse_grand_total(cls, v):
if isinstance(v, (str, float, int)):
return Decimal(str(v))
return v
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("mode", modes)
def test_decimal_extraction(client, model, mode):
client = instructor.from_provider(f"google/{model}", mode=mode, async_client=False)
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": "I bought 2 apples for $1.50 each and 3 bananas for $0.75 each. Calculate the total.",
},
],
response_model=Invoice,
)
assert isinstance(response, Invoice)
assert len(response.receipts) == 2
# Check apple receipt
apple_receipt = next(
(r for r in response.receipts if "apple" in r.item.lower()), None
)
assert apple_receipt is not None
assert apple_receipt.quantity == 2
assert isinstance(apple_receipt.price, Decimal)
assert isinstance(apple_receipt.total, Decimal)
# Check banana receipt
banana_receipt = next(
(r for r in response.receipts if "banana" in r.item.lower()), None
)
assert banana_receipt is not None
assert banana_receipt.quantity == 3
assert isinstance(banana_receipt.price, Decimal)
assert isinstance(banana_receipt.total, Decimal)
# Check grand total
assert isinstance(response.grand_total, Decimal)
@pytest.mark.asyncio
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("mode", modes)
async def test_decimal_extraction_async(aclient, model, mode):
aclient = instructor.from_provider(f"google/{model}", mode=mode, async_client=True)
response = await aclient.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": "I bought 1 coffee for $4.25 and 1 muffin for $2.75. What's the total?",
},
],
response_model=Invoice,
)
assert isinstance(response, Invoice)
assert len(response.receipts) == 2
# Check that all decimal fields are proper Decimal instances
for receipt in response.receipts:
assert isinstance(receipt.price, Decimal)
assert isinstance(receipt.total, Decimal)
assert isinstance(response.grand_total, Decimal)
class SimpleProduct(BaseModel):
name: str
price: Decimal
@field_validator("price", mode="before")
@classmethod
def parse_price(cls, v):
if isinstance(v, (str, float, int)):
return Decimal(str(v))
return v
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("mode", modes)
def test_simple_decimal_extraction(client, model, mode):
"""Test simple decimal extraction to ensure schema conversion works"""
client = instructor.from_provider(f"google/{model}", mode=mode, async_client=False)
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": "The laptop costs $999.99",
},
],
response_model=SimpleProduct,
)
assert isinstance(response, SimpleProduct)
assert response.name.lower() == "laptop"
assert isinstance(response.price, Decimal)
assert response.price == Decimal("999.99")

View File

@@ -0,0 +1,177 @@
import pytest
from pydantic import BaseModel
import instructor
from .util import models, modes
from itertools import product
from google import genai
from google.genai import types
class User(BaseModel):
name: str
age: int
class Users(BaseModel):
users: list[User]
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("mode", modes)
def test_simple_string_message(client, model, mode):
client = instructor.from_provider(f"google/{model}", mode=mode, async_client=False)
response = client.chat.completions.create(
model=model,
messages=["Ivan is 28 years old"], # type: ignore
response_model=Users,
)
assert isinstance(response, Users)
assert len(response.users) > 0
assert response.users[0].name == "Ivan"
assert response.users[0].age == 28
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("mode", modes)
def test_system_prompt(client, model, mode):
client = instructor.from_provider(f"google/{model}", mode=mode, async_client=False)
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "system",
"content": "Ivan is 28 years old",
},
{
"role": "user",
"content": "Make sure that the response is a list of users",
},
],
response_model=Users,
)
assert isinstance(response, Users)
assert len(response.users) > 0
assert response.users[0].name == "Ivan"
assert response.users[0].age == 28
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("mode", modes)
def test_system_kwarg(client, model, mode):
client = instructor.from_provider(f"google/{model}", mode=mode, async_client=False)
response = client.chat.completions.create(
model=model,
system="Ivan is 28 years old",
messages=[
{
"role": "user",
"content": "Make sure that the response is a list of users",
},
],
response_model=Users,
)
assert isinstance(response, Users)
assert len(response.users) > 0
assert response.users[0].name == "Ivan"
assert response.users[0].age == 28
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("mode", modes)
def test_system_kwarg_genai(client, model, mode):
client = instructor.from_provider(f"google/{model}", mode=mode, async_client=False)
response = client.chat.completions.create(
model=model,
system="Ivan is 28 years old",
messages=[
genai.types.Content(
role="user",
parts=[
genai.types.Part.from_text(
text="Make sure that the response is a list of users"
)
],
),
],
response_model=Users,
)
assert isinstance(response, Users)
assert len(response.users) > 0
assert response.users[0].name == "Ivan"
assert response.users[0].age == 28
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("mode", modes)
def test_system_prompt_list(client, model, mode):
client = instructor.from_provider(f"google/{model}", mode=mode, async_client=False)
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "system",
"content": [
"Ivan is",
" 28 years old",
],
}, # type: ignore
{
"role": "user",
"content": "Make sure that the response is a list of users",
},
],
response_model=Users,
)
assert isinstance(response, Users)
assert len(response.users) > 0
assert response.users[0].name == "Ivan"
assert response.users[0].age == 28
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("mode", modes)
def test_format_genai_typed(client, model, mode):
client = instructor.from_provider(f"google/{model}", mode=mode, async_client=False)
response = client.chat.completions.create(
model=model,
response_model=User,
messages=[
types.Content(
role="user",
parts=[
types.Part.from_text(text="Extract {{name}} is {{age}} years old")
],
), # type: ignore
],
context={"name": "Jason", "age": 25},
)
assert isinstance(response, User)
assert response.name == "Jason"
assert response.age == 25
@pytest.mark.parametrize("model, mode, is_list", product(models, modes, [True, False]))
def test_format_string(client, model: str, mode: instructor.Mode, is_list: bool):
client = instructor.from_provider(f"google/{model}", mode=mode, async_client=False)
content = (
["Extract {{name}} is {{age}} years old."]
if is_list
else "Extract {{name}} is {{age}} years old."
)
resp = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": content,
}
],
response_model=User,
context={"name": "Jason", "age": 25},
)
assert isinstance(resp, User)
assert resp.name == "Jason"
assert resp.age == 25

View File

@@ -0,0 +1,232 @@
import os
import pytest
from typing import Optional, Union
import instructor
from pydantic import BaseModel
from .util import models, modes
from itertools import product
from instructor.providers.gemini.utils import map_to_gemini_function_schema
MODEL = os.getenv("GOOGLE_GENAI_MODEL", "google/gemini-pro")
@pytest.mark.parametrize("mode,model", product(modes, models))
def test_nested(mode, model):
"""Test that nested schemas are supported."""
client = instructor.from_provider(f"google/{model}", mode=mode)
class Address(BaseModel):
street: str
city: str
class Person(BaseModel):
name: str
address: Optional[Address] = None
resp = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": "John loves to go gardenning with his friends",
}
],
response_model=Person,
)
assert resp.name == "John" # type: ignore
assert resp.address is None # type: ignore
@pytest.mark.parametrize("mode,model", product(modes, models))
def test_union(mode, model):
"""Test that union types are now supported with Gemini (issue #1964)."""
client = instructor.from_provider(f"google/{model}", mode=mode)
class UserData(BaseModel):
name: str
id_value: Union[str, int]
# Union types are now supported by Google GenAI SDK
# See: https://github.com/googleapis/python-genai/issues/447
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "User name is Alice with ID 12345"}],
response_model=UserData,
)
assert response.name == "Alice"
# The ID could be returned as either str or int
assert response.id_value in ["12345", 12345]
def test_optional_types_allowed():
"""Test that Optional types are correctly mapped and don't throw errors."""
class User(BaseModel):
name: str
age: Optional[int] = None
email: Optional[str] = None
schema = User.model_json_schema()
# Should not raise an error
result = map_to_gemini_function_schema(schema)
assert result["properties"]["age"]["nullable"] is True
assert result["properties"]["email"]["nullable"] is True
assert result["required"] == ["name"]
def test_union_types_allowed_schema():
"""Test that Union types are now allowed in schema mapping (issue #1964)."""
class UserWithUnion(BaseModel):
name: str
value: Union[int, str]
schema = UserWithUnion.model_json_schema()
# Union types are now supported - should not raise
result = map_to_gemini_function_schema(schema)
# The anyOf structure should be preserved
assert "properties" in result
assert "value" in result["properties"]
assert "anyOf" in result["properties"]["value"]
@pytest.mark.parametrize(
"mode", [instructor.Mode.GENAI_STRUCTURED_OUTPUTS, instructor.Mode.GENAI_TOOLS]
)
def test_genai_api_call_with_different_types(mode):
"""Test actual API call with genai SDK using different types."""
class UserProfile(BaseModel):
name: str
age: int
email: Optional[str] = None
is_premium: bool
score: float
client = instructor.from_provider(MODEL, mode=mode)
response = client.chat.completions.create(
messages=[
{
"role": "user",
"content": "Create a user profile for John Doe, 25 years old, premium user with score 85.5",
}
],
response_model=UserProfile,
)
assert isinstance(response, UserProfile)
assert response.name == "John Doe"
assert response.email is None
@pytest.mark.parametrize(
"mode", [instructor.Mode.GENAI_STRUCTURED_OUTPUTS, instructor.Mode.GENAI_TOOLS]
)
def test_genai_api_call_with_nested_models(mode):
"""Test API call with nested models (multiple users)."""
class User(BaseModel):
name: str
age: int
department: Optional[str] = None
class UserList(BaseModel):
users: list[User]
client = instructor.from_provider(MODEL, mode=mode)
response = client.chat.completions.create(
messages=[
{
"role": "user",
"content": "Create a list of 3 employees: Alice (30, Engineering), Bob (25, Marketing), Charlie (35)",
}
],
response_model=UserList,
)
assert isinstance(response, UserList)
assert len(response.users) == 3
assert {user.name for user in response.users} == {"Alice", "Bob", "Charlie"}
assert {user.age for user in response.users} == {25, 30, 35}
assert {user.department for user in response.users} == {
None,
"Engineering",
"Marketing",
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"mode", [instructor.Mode.GENAI_STRUCTURED_OUTPUTS, instructor.Mode.GENAI_TOOLS]
)
async def test_genai_api_call_with_different_types_async(mode):
"""Test actual async API call with genai SDK using different types."""
class UserProfile(BaseModel):
name: str
age: int
email: Optional[str] = None
is_premium: bool
score: float
client = instructor.from_provider(MODEL, mode=mode, async_client=True)
response = await client.chat.completions.create(
messages=[
{
"role": "user",
"content": "Create a user profile for John Doe, 25 years old, premium user with score 85.5",
}
],
response_model=UserProfile,
)
assert isinstance(response, UserProfile)
assert response.name == "John Doe"
assert response.email is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"mode", [instructor.Mode.GENAI_STRUCTURED_OUTPUTS, instructor.Mode.GENAI_TOOLS]
)
async def test_genai_api_call_with_nested_models_async(mode):
"""Test async API call with nested models (multiple users)."""
class User(BaseModel):
name: str
age: int
department: Optional[str] = None
class UserList(BaseModel):
users: list[User]
client = instructor.from_provider(MODEL, mode=mode, async_client=True)
response = await client.chat.completions.create(
messages=[
{
"role": "user",
"content": "Create a list of 3 employees: Alice (30, Engineering), Bob (25, Marketing), Charlie (35)",
}
],
response_model=UserList,
)
assert isinstance(response, UserList)
assert len(response.users) == 3
assert {user.name for user in response.users} == {"Alice", "Bob", "Charlie"}
assert {user.age for user in response.users} == {25, 30, 35}
assert {user.department for user in response.users} == {
None,
"Engineering",
"Marketing",
}

View File

@@ -0,0 +1,36 @@
import os
import pytest
from pydantic import BaseModel, field_validator
import instructor
@pytest.mark.parametrize("mode", [instructor.Mode.GENAI_TOOLS])
def test_genai_tools_validation_retry_preserves_model_content(mode):
"""Ensure GENAI_TOOLS validation retries are wired end-to-end."""
from instructor.core.exceptions import InstructorRetryException
model = os.getenv("GOOGLE_GENAI_MODEL", "gemini-2.0-flash")
class AlwaysInvalid(BaseModel):
value: int
@field_validator("value")
@classmethod
def always_fail(cls, v: int) -> int: # noqa: ARG003
raise ValueError("force retry for reask validation coverage")
client = instructor.from_provider(f"google/{model}", mode=mode)
with pytest.raises(InstructorRetryException) as exc_info:
client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": "Return any integer value",
}
],
response_model=AlwaysInvalid,
max_retries=2,
)
assert exc_info.value.n_attempts == 2

View File

@@ -0,0 +1,347 @@
"""Test schema conversion functions for Gemini."""
from enum import Enum
from typing import Optional
from pydantic import BaseModel
from instructor.providers.gemini.utils import (
map_to_gemini_function_schema,
verify_no_unions,
)
class Priority(Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class SimpleModel(BaseModel):
name: str
age: int
is_active: bool
class OptionalModel(BaseModel):
name: str
age: Optional[int] = None
description: Optional[str] = None
class EnumModel(BaseModel):
name: str
priority: Priority
class NestedModel(BaseModel):
name: str
items: list[str]
details: SimpleModel
def test_simple_schema_conversion():
"""Test conversion strips extra pydantic fields like 'title'."""
schema = SimpleModel.model_json_schema()
result = map_to_gemini_function_schema(schema)
# Input has 'title' fields that should be stripped out
assert "title" in schema
assert "title" in schema["properties"]["name"]
# Output should be clean without title fields
expected = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"is_active": {"type": "boolean"},
},
"required": ["name", "age", "is_active"],
}
assert result == expected
def test_optional_schema_conversion():
"""Test conversion transforms anyOf[T, null] to nullable fields."""
schema = OptionalModel.model_json_schema()
result = map_to_gemini_function_schema(schema)
# Input should have anyOf with null type for optional fields
assert schema["properties"]["age"]["anyOf"] == [
{"type": "integer"},
{"type": "null"},
]
assert schema["properties"]["description"]["anyOf"] == [
{"type": "string"},
{"type": "null"},
]
# Output should convert to nullable: true
expected = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer", "nullable": True},
"description": {"type": "string", "nullable": True},
},
"required": ["name"],
}
assert result == expected
def test_enum_schema_conversion():
"""Test conversion resolves $refs and adds format: enum."""
schema = EnumModel.model_json_schema()
result = map_to_gemini_function_schema(schema)
# Input should have $ref and $defs
assert schema["properties"]["priority"]["$ref"] == "#/$defs/Priority"
assert "$defs" in schema
assert schema["$defs"]["Priority"]["enum"] == ["low", "medium", "high"]
# Output should resolve the ref and add format: enum
expected = {
"type": "object",
"properties": {
"name": {"type": "string"},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"],
"format": "enum",
},
},
"required": ["name", "priority"],
}
assert result == expected
def test_nested_schema_conversion():
"""Test conversion of schema with nested objects."""
schema = NestedModel.model_json_schema()
result = map_to_gemini_function_schema(schema)
expected = {
"type": "object",
"properties": {
"name": {"type": "string"},
"items": {"type": "array", "items": {"type": "string"}},
"details": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"is_active": {"type": "boolean"},
},
"required": ["name", "age", "is_active"],
},
},
"required": ["name", "items", "details"],
}
assert result == expected
def test_verify_no_unions_valid():
"""Test verify_no_unions with valid schemas."""
# Simple schema should pass
simple_schema = SimpleModel.model_json_schema()
assert verify_no_unions(simple_schema) is True
# Optional schema should pass (Optional[T] is Union[T, None])
optional_schema = OptionalModel.model_json_schema()
assert verify_no_unions(optional_schema) is True
def test_verify_no_unions_invalid():
"""Test verify_no_unions with union schemas (now allowed)."""
# Create a schema with a true union (not just Optional)
invalid_schema = {
"type": "object",
"properties": {"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}},
}
assert verify_no_unions(invalid_schema) is True
def test_schema_without_refs():
"""Test schema conversion without $refs."""
schema = {
"type": "object",
"properties": {"name": {"type": "string"}, "count": {"type": "integer"}},
"required": ["name"],
}
result = map_to_gemini_function_schema(schema)
expected = {
"type": "object",
"properties": {"name": {"type": "string"}, "count": {"type": "integer"}},
"required": ["name"],
}
assert result == expected
def test_schema_with_description():
"""Test schema conversion preserves descriptions."""
schema = {
"type": "object",
"description": "A test object",
"properties": {"name": {"type": "string", "description": "The name field"}},
}
result = map_to_gemini_function_schema(schema)
expected = {
"type": "object",
"description": "A test object",
"properties": {"name": {"type": "string", "description": "The name field"}},
}
assert result == expected
def test_union_type_raises_error():
"""Test that union types are allowed in schema conversion."""
# Create a model with a true union type (not Optional or Decimal)
union_schema = {
"type": "object",
"properties": {"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}},
}
result = map_to_gemini_function_schema(union_schema)
assert result["properties"]["value"]["anyOf"] == [
{"type": "string"},
{"type": "integer"},
]
def test_verify_no_unions_allows_optional():
"""Test that verify_no_unions allows Optional types."""
# Schema with Optional field (Union with null)
optional_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
},
}
assert verify_no_unions(optional_schema) is True
def test_verify_no_unions_allows_decimal():
"""Test that verify_no_unions allows Decimal types (string | number)."""
# Schema with Decimal field (Union of string and number)
decimal_schema = {
"type": "object",
"properties": {
"total": {"anyOf": [{"type": "number"}, {"type": "string"}]},
"price": {
"anyOf": [{"type": "string"}, {"type": "number"}]
}, # Order shouldn't matter
},
}
assert verify_no_unions(decimal_schema) is True
def test_verify_no_unions_rejects_other_unions():
"""Test that verify_no_unions allows non-Optional unions."""
# Schema with unsupported union type (string | integer)
union_schema = {
"type": "object",
"properties": {"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}},
}
assert verify_no_unions(union_schema) is True
def test_verify_no_unions_rejects_complex_unions():
"""Test that verify_no_unions allows complex union types."""
# Schema with more than 2 types in union
complex_union_schema = {
"type": "object",
"properties": {
"value": {
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "boolean"}]
}
},
}
assert verify_no_unions(complex_union_schema) is True
def test_verify_no_unions_nested_schemas():
"""Test that verify_no_unions allows unions in nested schemas."""
# Schema with nested object containing Decimal and Optional fields
nested_schema = {
"type": "object",
"properties": {
"receipt": {
"type": "object",
"properties": {
"total": {
"anyOf": [{"type": "number"}, {"type": "string"}]
}, # Decimal - should pass
"notes": {
"anyOf": [{"type": "string"}, {"type": "null"}]
}, # Optional - should pass
},
}
},
}
assert verify_no_unions(nested_schema) is True
# Schema with nested object containing unsupported union
bad_nested_schema = {
"type": "object",
"properties": {
"receipt": {
"type": "object",
"properties": {
"total": {
"anyOf": [{"type": "number"}, {"type": "string"}]
}, # Decimal - should pass
"status": {
"anyOf": [{"type": "string"}, {"type": "integer"}]
}, # Bad union - should fail
},
}
},
}
assert verify_no_unions(bad_nested_schema) is True
def test_decimal_schema_conversion_succeeds():
"""Test that Decimal types (string | number) are successfully converted."""
# Schema representing a Receipt with Decimal total field
decimal_schema = {
"type": "object",
"title": "Receipt",
"properties": {
"total": {
"anyOf": [{"type": "number"}, {"type": "string"}],
"title": "Total",
}
},
"required": ["total"],
}
# This should not raise an error now
result = map_to_gemini_function_schema(decimal_schema)
# The conversion should succeed and preserve the anyOf structure
assert result["type"] == "object"
assert result["properties"]["total"]["anyOf"] == [
{"type": "number"},
{"type": "string"},
]
assert result["required"] == ["total"]
# Title should be stripped out
assert "title" not in result
assert "title" not in result["properties"]["total"]

View File

@@ -0,0 +1,359 @@
from instructor.providers.gemini.utils import update_genai_kwargs
def test_update_genai_kwargs_basic():
"""Test basic parameter mapping from OpenAI to Gemini format."""
kwargs = {
"generation_config": {
"max_tokens": 100,
"temperature": 0.7,
"n": 2,
"top_p": 0.9,
"stop": ["END"],
"seed": 42,
"presence_penalty": 0.1,
"frequency_penalty": 0.2,
}
}
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
# Check that OpenAI parameters were mapped to Gemini equivalents
assert result["max_output_tokens"] == 100
assert result["temperature"] == 0.7
assert result["candidate_count"] == 2
assert result["top_p"] == 0.9
assert result["stop_sequences"] == ["END"]
assert result["seed"] == 42
assert result["presence_penalty"] == 0.1
assert result["frequency_penalty"] == 0.2
def test_update_genai_kwargs_safety_settings():
"""Test that safety settings are properly configured."""
from google.genai.types import HarmCategory, HarmBlockThreshold
# Exclude JAILBREAK category as it's only for Vertex AI, not google.genai
excluded_categories = {HarmCategory.HARM_CATEGORY_UNSPECIFIED}
if hasattr(HarmCategory, "HARM_CATEGORY_JAILBREAK"):
excluded_categories.add(HarmCategory.HARM_CATEGORY_JAILBREAK)
supported_categories = [
c
for c in HarmCategory
if c not in excluded_categories
and not c.name.startswith("HARM_CATEGORY_IMAGE_")
]
kwargs = {}
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
# Check that safety_settings is configured as a list
assert "safety_settings" in result
assert isinstance(result["safety_settings"], list)
# Should have one entry for each supported HarmCategory
assert len(result["safety_settings"]) == len(supported_categories)
# Each entry should be a dict with category and threshold
for setting in result["safety_settings"]:
assert isinstance(setting, dict)
assert "category" in setting
assert "threshold" in setting
assert setting["threshold"] == HarmBlockThreshold.OFF # Default
def test_update_genai_kwargs_with_custom_safety_settings():
"""Test that custom safety settings are properly handled."""
from google.genai.types import HarmCategory, HarmBlockThreshold
# Exclude JAILBREAK category as it's only for Vertex AI, not google.genai
excluded_categories = {HarmCategory.HARM_CATEGORY_UNSPECIFIED}
if hasattr(HarmCategory, "HARM_CATEGORY_JAILBREAK"):
excluded_categories.add(HarmCategory.HARM_CATEGORY_JAILBREAK)
supported_categories = [
c
for c in HarmCategory
if c not in excluded_categories
and not c.name.startswith("HARM_CATEGORY_IMAGE_")
]
# Test with one category that exists in safety_settings
custom_safety = {
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
}
kwargs = {"safety_settings": custom_safety}
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
# Check that safety_settings is configured as a list
assert "safety_settings" in result
assert isinstance(result["safety_settings"], list)
# Should have one entry for each supported HarmCategory
assert len(result["safety_settings"]) == len(supported_categories)
for setting in result["safety_settings"]:
if setting["category"] == HarmCategory.HARM_CATEGORY_HATE_SPEECH:
assert setting["threshold"] == HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
# Other categories should use the default
for setting in result["safety_settings"]:
if setting["category"] != HarmCategory.HARM_CATEGORY_HATE_SPEECH:
assert setting["threshold"] == HarmBlockThreshold.OFF
def test_update_genai_kwargs_safety_settings_excludes_image_categories():
"""IMAGE_* harm categories must never be sent to the standard Gemini API.
They are only supported by Vertex AI and cause 400 INVALID_ARGUMENT errors.
See: https://github.com/567-labs/instructor/issues/2146
"""
from google.genai import types
kwargs = {
"contents": [
types.Content(
role="user",
parts=[types.Part.from_bytes(data=b"123", mime_type="image/png")],
)
]
}
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
assert "safety_settings" in result
assert isinstance(result["safety_settings"], list)
# No IMAGE_* categories should be present, even with image content
for setting in result["safety_settings"]:
assert not setting["category"].name.startswith("HARM_CATEGORY_IMAGE_"), (
f"IMAGE_ category {setting['category'].name} must not be sent to the "
"standard Gemini API"
)
def test_update_genai_kwargs_text_categories_with_image_content():
"""Even with image content, only text harm categories should be used."""
from google.genai import types
from google.genai.types import HarmBlockThreshold, HarmCategory
custom_safety = {
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
}
kwargs = {
"contents": [
types.Content(
role="user",
parts=[types.Part.from_bytes(data=b"123", mime_type="image/png")],
)
],
"safety_settings": custom_safety,
}
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
# Custom threshold should be preserved for text category
found_hate_speech = False
for setting in result["safety_settings"]:
assert not setting["category"].name.startswith("HARM_CATEGORY_IMAGE_")
if setting["category"] == HarmCategory.HARM_CATEGORY_HATE_SPEECH:
assert setting["threshold"] == HarmBlockThreshold.BLOCK_LOW_AND_ABOVE
found_hate_speech = True
assert found_hate_speech, "HARM_CATEGORY_HATE_SPEECH should be in safety_settings"
def test_update_genai_kwargs_none_values():
"""Test that None values are not set in the result."""
kwargs = {
"generation_config": {
"max_tokens": None,
"temperature": 0.7,
"n": None,
}
}
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
# Check that None values are not included
assert "max_output_tokens" not in result
assert "candidate_count" not in result
assert result["temperature"] == 0.7
def test_update_genai_kwargs_empty():
"""Test with empty kwargs."""
kwargs = {}
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
# Should still have safety_settings configured
assert "safety_settings" in result
def test_update_genai_kwargs_preserves_original():
"""Test that the function doesn't modify the original kwargs."""
original_kwargs = {
"generation_config": {
"max_tokens": 100,
"temperature": 0.7,
},
"safety_settings": {},
}
kwargs = original_kwargs.copy()
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
# The function should not modify the original kwargs (works on a copy)
assert kwargs == original_kwargs
# But result should have the mapped parameters
assert "max_output_tokens" in result
assert "temperature" in result
def test_update_genai_kwargs_thinking_config():
"""Test that thinking_config is properly passed through."""
thinking_config = {"thinking_budget": 1024}
kwargs = {"thinking_config": thinking_config}
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
# Check that thinking_config is passed through unchanged
assert "thinking_config" in result
assert result["thinking_config"] == thinking_config
def test_update_genai_kwargs_thinking_config_none():
"""Test that None thinking_config is not included in result."""
kwargs = {"thinking_config": None}
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
# Check that thinking_config is not included when None
assert "thinking_config" not in result
def test_update_genai_kwargs_no_thinking_config():
"""Test that missing thinking_config doesn't affect other parameters."""
kwargs = {
"generation_config": {
"max_tokens": 100,
"temperature": 0.7,
}
}
base_config = {}
result = update_genai_kwargs(kwargs, base_config)
# Check that normal parameters still work
assert result["max_output_tokens"] == 100
assert result["temperature"] == 0.7
# Check that thinking_config is not included when not provided
assert "thinking_config" not in result
def test_handle_genai_structured_outputs_thinking_config_in_config():
"""Test that thinking_config inside config parameter is extracted (issue #1966)."""
from google.genai import types
from pydantic import BaseModel
from instructor.providers.gemini.utils import handle_genai_structured_outputs
class SimpleModel(BaseModel):
text: str
# Create a mock ThinkingConfig-like object
thinking_config = types.ThinkingConfig(thinking_budget=1024)
# User passes thinking_config inside config parameter
user_config = types.GenerateContentConfig(
temperature=0.7,
max_output_tokens=1000,
thinking_config=thinking_config,
)
kwargs = {
"messages": [{"role": "user", "content": "Hello"}],
"config": user_config,
}
_, result_kwargs = handle_genai_structured_outputs(SimpleModel, kwargs)
# The resulting config should include thinking_config
assert "config" in result_kwargs
assert result_kwargs["config"].thinking_config is not None
assert result_kwargs["config"].thinking_config.thinking_budget == 1024
def test_handle_genai_structured_outputs_thinking_config_kwarg_priority():
"""Test that thinking_config as separate kwarg takes priority over config.thinking_config."""
from google.genai import types
from pydantic import BaseModel
from instructor.providers.gemini.utils import handle_genai_structured_outputs
class SimpleModel(BaseModel):
text: str
# User passes thinking_config both ways - kwarg should take priority
config_thinking = types.ThinkingConfig(thinking_budget=500)
kwarg_thinking = types.ThinkingConfig(thinking_budget=2000)
user_config = types.GenerateContentConfig(
temperature=0.7,
thinking_config=config_thinking,
)
kwargs = {
"messages": [{"role": "user", "content": "Hello"}],
"config": user_config,
"thinking_config": kwarg_thinking,
}
_, result_kwargs = handle_genai_structured_outputs(SimpleModel, kwargs)
# The kwarg thinking_config should take priority
assert result_kwargs["config"].thinking_config.thinking_budget == 2000
def test_handle_genai_tools_thinking_config_in_config():
"""Test that thinking_config inside config parameter is extracted for tools mode (issue #1966)."""
from google.genai import types
from pydantic import BaseModel
from instructor.providers.gemini.utils import handle_genai_tools
class SimpleModel(BaseModel):
text: str
thinking_config = types.ThinkingConfig(thinking_budget=1024)
user_config = types.GenerateContentConfig(
temperature=0.7,
thinking_config=thinking_config,
)
kwargs = {
"messages": [{"role": "user", "content": "Hello"}],
"config": user_config,
}
_, result_kwargs = handle_genai_tools(SimpleModel, kwargs)
# The resulting config should include thinking_config
assert "config" in result_kwargs
assert result_kwargs["config"].thinking_config is not None
assert result_kwargs["config"].thinking_config.thinking_budget == 1024

View File

@@ -0,0 +1,5 @@
import os
import instructor
models = [os.getenv("GOOGLE_GENAI_MODEL", "google/gemini-2.0-flash")]
modes = [instructor.Mode.GENAI_STRUCTURED_OUTPUTS]